feat(accounts): an account names its forge and server, and the command line takes --forge and --server #205

Merged
james.maes merged 4 commits from feat/204-gitea-accounts-and-cli into main 2026-11-11 22:22:17 +00:00
29 changed files with 3155 additions and 346 deletions

No files matched your search

+4 -2
View File
@@ -123,8 +123,10 @@ together are the "Core": all logic lives there, under the coverage gate.
single seam for per-user data (`%LOCALAPPDATA%\KofTwentyTwo\gclo`, overridden by
`GCLO_DATA_DIR`, and deliberately not the Velopack install root, which Setup.exe
deletes); `AppSettings` → `settings.json`; `AccountsStore` → `accounts.json`
(metadata only) with tokens in an `ITokenVault` (`CredentialManagerVault`, or
`InMemoryVault`). The file and the vault have no shared transaction, so every
for GitHub accounts and `accounts-forges.json` for the others (an older gclo
rewrites the first with the fields it knows, so an account on another forge is
never put there; ADR 0010), metadata only, with tokens in an `ITokenVault`
(`CredentialManagerVault`, or `InMemoryVault`). The file and the vault have no shared transaction, so every
operation touching both has a fixed order and a compensating step: keep that when
changing `AccountsStore`.
- **`gclo.Cli`**: `Program` dispatches to `SyncCommand`, `ReposCommand`, `OrgsCommand`,
+16 -2
View File
@@ -183,10 +183,24 @@ gclo sync --org contoso --target ~/src/contoso --json |
| 0 | Everything succeeded. |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). |
| 2 | Fatal: bad arguments, missing or empty token, or organization not found. |
| 3 | GitHub rejected the token or refused it access: fix the token, do not retry as is. |
| 4 | A rate limit, a network failure, a timeout, or an error on GitHub's side: retry later. |
| 3 | GitHub (or the Gitea server) rejected the token or refused it access: fix the token, do not retry as is. |
| 4 | A rate limit, a network failure, a timeout, or an error on the server's side: retry later. |
| 70 | An unexpected error inside gclo; details are in the activity log. |
**A Gitea server instead of GitHub** (also Forgejo, which speaks the same API): name the server with `--server`. The token comes from `GITEA_TOKEN` then, never from `GITHUB_TOKEN`, and it is sent to that server only (HTTPS; redirects are not followed).
```powershell
$env:GITEA_TOKEN = (Get-Secret -Name GiteaToken -AsPlainText)
gclo orgs --server https://git.example.com
gclo sync --server https://git.example.com --org platform --target D:\mirror\platform
# As a saved account; the desktop app syncs it too
Get-Secret -Name GiteaToken -AsPlainText | gclo accounts add --name platform --org platform --target D:\mirror\platform --server https://git.example.com --token-stdin
gclo sync --account platform
```
An account's forge and server are fixed once it is added (its token was saved for that place); the app's wizard adds GitHub accounts, a Gitea account is added from the command line for now.
Full reference — all options, output streams, Ctrl+C behavior, and more examples: [docs/CLI.md](docs/CLI.md).
## How it works
+107 -29
View File
@@ -2,8 +2,8 @@
`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.
(or user account), or of one on a Gitea server, into a local folder, and
fast-forwards the ones that already exist there.
## Building and running
@@ -25,15 +25,20 @@ and runs on Linux/macOS hosts with the .NET 10 SDK.
```
gclo sync --org <name> --target <folder> [--parallel N] [--sanitize-paths]
[--forge github|gitea] [--server ADDRESS]
[--include GLOB]... [--exclude GLOB]... [--skip-archived] [--dry-run]
[--token-env VAR | --token-file PATH | --token-stdin]
[--json | --json-lines] [--quiet]
gclo sync --account <name> [same options — each one overrides the account value]
gclo repos --org <name> [--include GLOB]... [--exclude GLOB]... [--skip-archived]
gclo sync --account <name> [same options — each one overrides the account value;
not --forge or --server: the account says where it lives]
gclo repos --org <name> [--forge github|gitea] [--server ADDRESS]
[--include GLOB]... [--exclude GLOB]... [--skip-archived]
[--token-env VAR | --token-file PATH | --token-stdin] [--json]
gclo orgs [--forge github|gitea] [--server ADDRESS]
[--token-env VAR | --token-file PATH | --token-stdin] [--json]
gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json]
gclo accounts [list] [--json]
gclo accounts add --name <name> --org <name> --target <folder> [--parallel N]
[--forge github|gitea] [--server ADDRESS]
[--org-subfolder] [--description TEXT] [token option]
gclo accounts edit --name <name> [--rename NEW] [--org NAME] [--target FOLDER]
[--parallel N] [--org-subfolder | --no-org-subfolder] [--description TEXT]
@@ -262,14 +267,64 @@ without the `read:org` scope) still prints its own account login — you can pas
any organization name to `gclo sync` manually. A rate limit or temporary
throttle is reported as such (exit code 4) rather than shrinking the list.
### GitHub, or a Gitea server: `--forge` and `--server`
Without either option a command talks to GitHub. For an organization on a Gitea
server (or a Forgejo server, which speaks the same API), name the server:
```powershell
$env:GITEA_TOKEN = (Get-Secret -Name GiteaToken -AsPlainText)
gclo orgs --server https://git.example.com
gclo repos --server https://git.example.com --org platform
gclo sync --server https://git.example.com --org platform --target D:\mirror\platform
```
- `--server <address>` is the server's own address. `--forge gitea` goes with it
and may be left out; `--forge github` is the default and takes no server.
- **HTTPS only.** `http://` is refused, and nothing turns certificate checks
off. A server with a certificate from a private authority works once Windows
(or the system gclo runs on) trusts that authority.
- **The token goes to that server and nowhere else.** gclo does not follow a
redirect the server answers with, and clones from the address you gave, not
from an address the server names. If the server answers with a redirect, the
address is not the one the server itself uses, or the organization was
renamed; the message says so.
- **Each forge has its own default token variable**: `GITHUB_TOKEN` for GitHub,
`GITEA_TOKEN` for a Gitea server. One is never used for the other, so a GitHub
token that happens to be in the environment is not sent to a Gitea server.
`--token-env`, `--token-file` and `--token-stdin` work as everywhere.
- A Gitea access token needs to be allowed to read repositories, organizations
and the user (`read:repository`, `read:organization`, `read:user`). Without
the second, `gclo orgs` prints the account alone.
- A server that is reachable only through a sign-in page or a single-sign-on
proxy does not work: gclo reports that the address does not answer like a
Gitea server.
## Accounts
An *account* is a saved sync profile: a name, an organization, a target root
folder, a parallelism setting, an org-subfolder preference, an optional
description, and a token stored in **Windows Credential Manager** (under
`gclo:account:<id>`, or `gclo:<scope>:account:<id>` when `GCLO_DATA_DIR` points
the data elsewhere; the metadata file,
`%LOCALAPPDATA%\KofTwentyTwo\gclo\accounts.json`, never contains the token).
An *account* is a saved sync profile: a name, an organization, where it lives
(GitHub, or a Gitea server and its address), a target root folder, a parallelism
setting, an org-subfolder preference, an optional description, and a token
stored in **Windows Credential Manager** (under `gclo:account:<id>`, or
`gclo:<scope>:account:<id>` when `GCLO_DATA_DIR` points the data elsewhere; the
metadata file, `%LOCALAPPDATA%\KofTwentyTwo\gclo\accounts.json`, never contains
the token).
Accounts on a Gitea server are kept in a file of their own beside it,
`accounts-forges.json`. A version of gclo from before 1.2 knows only GitHub: it
reads every account in `accounts.json` as a GitHub account and writes the file
back with what it knows. It neither reads nor writes the other file, so it does
not list such accounts, and using it beside 1.2 leaves them as they are. One
thing it can do: add a GitHub account with the name of an account it does not
see. gclo then does not choose between the two: `--account <name>`,
`accounts edit` and `accounts remove` fail (exit 2) and say that two accounts
have the name, until one of them is renamed in the desktop app.
The token of such an account is kept under an entry name made from the account's
id, its forge and its server address as the file states them, not under `<id>`.
It is found only by that account at that address: when the address is changed in
the file by hand, the account has no token there, instead of sending the one it
had to the new address.
Accounts are shared between the CLI and the desktop app: create them with
`gclo accounts add` on a headless mirror server, or in the app's account
wizard, and use them from either.
@@ -291,25 +346,31 @@ few seconds and then exits with code 4; run it again.
### `gclo accounts` / `gclo accounts list`
Lists the saved accounts, one per line, in aligned columns: name, organization,
target root, token source (`own` or `default`), and last sync time (local time,
`never` when the account has not completed a sync yet):
where it lives (`GitHub`, or the host of a Gitea server), target root, token
source (`own` or `default`), and last sync time (local time, `never` when the
account has not completed a sync yet):
```
work contoso C:\src\contoso own 2026-07-04 09:12
personal octocat D:\mirror default never
work contoso GitHub C:\src\contoso own 2026-07-04 09:12
personal octocat GitHub D:\mirror default never
platform platform git.example.com D:\platform own never
```
With `--json` it prints a single-line JSON array with everything a script needs
to reason about a run; `lastSync` is the UTC timestamp of the last completed
sync and `lastSyncSummary` its summary line, both `null` until the first sync;
`tokenSource` is `own` or `default`:
`tokenSource` is `own` or `default`; `forge` is `github` or `gitea` (`unknown`
for an account a later version of gclo added on a forge this one does not know),
and `server` is the address of a Gitea account's server as the token is sent to
it (`null` for GitHub, and for an account whose stored address cannot be used).
The last two fields are new in 1.2:
```json
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}]
```
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`)
for an `own` account; a `default` account has no entry of its own and resolves
for an `own` account on GitHub; a `default` account has no entry of its own and resolves
to the default token saved in the desktop app's Settings whenever it syncs.
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
hint goes to stderr; the exit code is still 0.
@@ -328,15 +389,28 @@ account is used later from other places; a target that cannot be a folder
`%NAME%`) is refused. `--description=` (or `--description ""`) is an empty
description. The token comes from a
token option exactly as for `gclo sync` (default: the `GITHUB_TOKEN` environment
variable) and is written to Windows Credential Manager — never put it on the
command line. A name that another account already uses (case-insensitive)
exits with code 2.
variable, or `GITEA_TOKEN` for a Gitea server) and is written to Windows
Credential Manager — never put it on the command line. A name that another
account already uses (case-insensitive) exits with code 2.
For an account on a Gitea server, add `--server` (see "GitHub, or a Gitea
server"):
```powershell
Get-Secret -Name GiteaToken -AsPlainText | gclo accounts add --name platform --org platform --target D:\platform --server https://git.example.com --token-stdin
```
The forge and the server are **fixed once the account is added**. The token
was saved for that place, and editing an address must not send it somewhere
else: to move an account, remove it and add it again.
With `--use-default-token` instead of a token option, the account holds no
token of its own: it resolves to the default token saved in the desktop app's
Settings every time it syncs, so replacing that token in Settings updates every
such account at once. The command fails (exit 2) when no default token is saved,
because the account could never sync; the CLI cannot set the default token.
The default token is a GitHub token: `--use-default-token` is for GitHub
accounts only, and an account on a Gitea server always has a token of its own.
```powershell
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
@@ -355,7 +429,8 @@ Without a token option the token (and its source) is left untouched. A
`--token-*` option gives the account a token of its own (also switching a
`default` account to `own`); `--use-default-token` switches it to the default
token and removes its own entry from Credential Manager. Editing with nothing
to change is a usage error.
to change is a usage error. `--forge` and `--server` are refused: where an
account lives cannot be changed (remove it and add it again).
### `gclo accounts remove`
@@ -368,8 +443,9 @@ on disk are not touched.
### `gclo sync --account <name>`
Runs a sync with the account's settings and its stored token — no `--org`,
`--target`, or token option needed:
Runs a sync with the account's settings and its stored token, from the forge
and server the account was added for — no `--org`, `--target`, or token option
needed (`--forge` and `--server` are not accepted with `--account`):
```powershell
gclo sync --account work
@@ -402,7 +478,8 @@ Notes:
## Providing the token
gclo needs a GitHub Personal Access Token for both the API and the git transport.
gclo needs a token for both the API and the git transport: a GitHub Personal
Access Token, or an access token of the Gitea server.
> **Why is there no `--token <value>` option?**
> Command-line arguments are visible to every other process on the machine —
@@ -413,7 +490,7 @@ gclo needs a GitHub Personal Access Token for both the API and the git transport
| Option | Behavior |
| --- | --- |
| *(none)* | Reads the `GITHUB_TOKEN` environment variable. |
| *(none)* | Reads the `GITHUB_TOKEN` environment variable; for a Gitea server (`--server`) the `GITEA_TOKEN` variable instead. Neither is used for the other forge. |
| `--token-env VAR` | Reads environment variable `VAR`. |
| `--token-file PATH` | Reads the first non-blank line of `PATH`, trimmed. |
| `--token-stdin` | Reads one line from standard input — made for piping from a secret store. |
@@ -422,8 +499,9 @@ The options are mutually exclusive. A missing or empty token prints an error to
stderr and exits with code 2.
With `gclo sync --account`, the default source is the account's token in
Windows Credential Manager instead of `GITHUB_TOKEN`; any token option above
still wins for that run (the stored token is left untouched).
Windows Credential Manager instead of an environment variable; any token option
above still wins for that run (the stored token is left untouched), and is sent
to the place the account lives.
## Activity log
@@ -452,8 +530,8 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
| 0 | Everything succeeded (or `--dry-run` / a listing completed). | — |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). `--json`'s `wasCanceled` tells the two apart. | Inspect the failures; retry them. |
| 2 | Fatal: bad arguments, missing/empty token, organization not found, unknown account, missing account token, unusable target folder, or an account command on a non-Windows OS. | Fix the invocation. |
| 3 | GitHub rejected the token (401), or refused it access (403: the token may not see the organization, or the organization requires SAML single sign-on and the token is not authorized for it). The message carries GitHub's reason when it gave one. | Fix the token or its access; do not retry as is. |
| 4 | Not your doing: GitHub's primary or secondary rate limit (403 or 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, an error on GitHub's side (HTTP 5xx), or the accounts file being changed by another gclo process at that moment. | Retry later (for a rate limit, the message says when). |
| 3 | The forge (GitHub, or the Gitea server) rejected the token (401), or refused it access (403: the token may not see the organization, the organization requires SAML single sign-on and the token is not authorized for it, or a Gitea token lacks a scope). The message carries the server's reason when it gave one. | Fix the token or its access; do not retry as is. |
| 4 | Not your doing: a rate limit (GitHub's primary or secondary one, 403 or 429; a Gitea server's 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, an error on the server's side (HTTP 5xx), or the accounts file being changed by another gclo process at that moment. | Retry later (for a rate limit, the message says when). |
| 70 | An unexpected error inside gclo. The exception type is printed and the full details are in the activity log. | Please report it. |
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
+56
View File
@@ -69,6 +69,59 @@ were the two lister implementations and the failure type they throw.
whatever the name is; the account path is tried then as well, so such a token
still lists its own account.
### Accounts and the command line (#204)
- **An account names its forge and, for Gitea, its server.** Both are left out
when they are the default, so a GitHub account is written as it always was.
- **Accounts on another forge are kept in a file of their own**
(`accounts-forges.json`, beside `accounts.json`, under the same lock). A version
from before forges reads every account in `accounts.json` as a GitHub account
and writes the file back with the fields it knows. It would drop the forge and
the server of a Gitea account, and from then on that account would read as a
GitHub account in every version: its token would be asked for again and sent to
GitHub. Two versions on one data folder are ordinary (the app updates itself,
the command line is a separate download), so such accounts are kept where the
older version neither reads nor writes. A GitHub account is the one that names
no forge, and only that one; a record in the second file that names none is not
read as a GitHub account (gclo writes no such record; one that is found is
given an empty forge name, which no version knows, and is written back so). The cost is two files where there was one. A change
itself writes only the file of the account it changes; a file that has to be put
back, or an account that is in the wrong file, is written before the change, so
that when it cannot be written the change fails with nothing changed and the
vault untouched. A name is unique across both files, but an older version does
not see the second and can add a GitHub account with the name of a Gitea
account: the command line then refuses the name instead of choosing one of the
two, until one is renamed in the app.
- **What this version does not know is kept.** The kind of forge is stored as
text; a name this version does not know (a later version's forge) makes that
one account unusable here, not the file unreadable, and is written back as it
was. Fields this version does not know are written back as they were read,
whoever built the record that is saved. So this version does not do to a later
one what an older one would do to it. The limits a later version has to keep
to: a field this version knows keeps its JSON type (text where text is, as for
`Forge`; another type makes the file unreadable here), and a new field is not
named like one of this version's computed properties.
- **A token stays with the place it was saved for.** The forge and the server of
an account are set when it is added and cannot be changed by an edit, in the
app or on the command line: to move an account, it is removed and added again,
and its token is entered for the new place. `gclo sync --account` takes no
`--forge` or `--server`.
- **A token is found only by the account and the place it was saved for.** The
vault entry of a GitHub account is its id, as ever. For another forge it is made
from the id, the forge and the server address exactly as the file states them
(a hash). `AccountsStore.Save` refuses a change of place; this makes the same
hold for a file changed by hand: at another address the account has no token.
The price: a token whose account was edited that way stays in Credential Manager
under a name gclo no longer derives, until the address is put back.
- **Tokens do not cross forges by default.** The default token saved in Settings
is a GitHub token: an account on another forge has a token of its own, and is
never handed the default one, whatever its record says. On the command line
each forge has its own default variable (`GITHUB_TOKEN`, `GITEA_TOKEN`).
- **One place pairs an account with its listers** (`AccountListers`, over
`ForgeEndpoint`), used by the app and the command line. An account whose stored
forge or server cannot be used (a file edited by hand) gets listers that ask no
server anything and fail with the reason.
## Alternatives considered
- **A Gitea client package.** One more dependency to track and to trust with the
@@ -79,6 +132,9 @@ were the two lister implementations and the failure type they throw.
address also makes such a server work.
- **Following redirects on the same host.** Simpler to refuse them all than to
decide which are the same host.
- **Letting an edit change an account's server**, with the token entered again.
The rule "the token is asked for again" would have to hold in the wizard, the
command line and the store; "it cannot be changed" holds in the store alone.
- **Allowing `http://` for a server on a local network, or a switch to accept any
certificate.** Both put the token on the wire, or in the hands of whoever answers,
in exactly the setups where nobody is watching. The supported route for a private
+5 -4
View File
@@ -27,7 +27,7 @@ about the user's accounts or repositories. It sends nothing anywhere else.
| gclo app / CLI | `gclo.exe` (WinUI 3) and the CLI `gclo.exe`, same engine | Trusted; runs as the user |
| GitHub REST API | Lists organizations and repositories | Untrusted input (responses are parsed), authenticated by the PAT |
| Git remotes (`github.com`) | Clone and fetch over HTTPS via libgit2 | Untrusted input (repository content, including file paths) |
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). At this revision in the engine library only: accounts, the CLI and the app do not use it yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
| Gitea server at an address the user names | Lists organizations and repositories through its REST API (ADR 0010). Used by accounts and the command line (#204); the desktop app's wizard cannot add such an account yet (#201) | Untrusted beyond being the place the user chose to send the account's token to: its answers are parsed, bounded, and never decide where the token goes |
| Windows Credential Manager | Stores saved-account and default tokens | Trusted store; boundary is the Windows user account |
| Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user |
| Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) |
@@ -64,7 +64,7 @@ flowchart LR
| Git HTTPS transport (libgit2) | out/in | Repository objects and trees | PAT as HTTPS basic (`x-access-token`) | `WindowsPathValidator` checks every incoming path against Windows rules before checkout; non-fast-forward is reported, never merged |
| PAT entry | in | The token | n/a | App: in-memory or Credential Manager; CLI: env var, file, or stdin only (no `--token <value>`) |
| Credential Manager | out/in | Tokens by `gclo[:<scope>]:account:<id>` target | Windows user session | `CredentialManagerVault`; scope derived from the data root |
| `settings.json`, `accounts.json` (with `accounts.json.lock`) | out/in | Non-secret settings and account metadata | File ACL (user profile) | Written beside the file and put in place in one step; sanitized on load; an accounts file whose content is not valid is moved aside (`.corrupt-*`), one that cannot be read is left in place; the previous accounts file is kept as `.bak`. Changes to the accounts are serialized across gclo processes by the lock file: a process of the same user that holds it open can delay account changes (they fail after a few seconds, nothing is written), not read or alter them |
| `settings.json`, `accounts.json`, `accounts-forges.json` (with `accounts.json.lock`) | out/in | Non-secret settings and account metadata | File ACL (user profile) | Written beside the file and put in place in one step; sanitized on load; an accounts file whose content is not valid is moved aside (`.corrupt-*`), one that cannot be read is left in place; the previous accounts file is kept as `.bak`. Changes to the accounts are serialized across gclo processes by the lock file: a process of the same user that holds it open can delay account changes (they fail after a few seconds, nothing is written), not read or alter them |
| Activity logs | out | Actions and errors; token *lengths* only | File ACL | `IActivityLog` callers never pass tokens |
| `.git/gclo-recovery.json`, `gclo.checkoutpending` and `gclo.recoverymanaged` config keys | out/in | Path-recovery plan; checkout-pending marker; "gclo wrote this working tree" marker | File ACL | Written atomically; read only for the repository it belongs to (the markers from the repository's own configuration, never the user's or the machine's). A repository marked as written by gclo has its working tree rewritten on sync; one that is not, and was checked out by git, is never rewritten |
| Command line (CLI) | in | Options, org, target, filters | n/a | `OptionReader` / `RepoFilterSpec`; parallelism bounded 1..64; token options refused on argv |
@@ -100,8 +100,8 @@ flowchart LR
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another | Accepted (same-user boundary: whoever can write these files can read the tokens) |
## 7. Accepted risks
@@ -133,3 +133,4 @@ flowchart LR
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Corrections from the release reviews (#183): the header names the version reviewed; section 1 says that an installed build contacts `git.kof22.com` for updates; the accepted risks no longer say the binaries are unsigned (they are signed, ADR 0008) and name T1 and the Scoop part of T2 instead; T2 and T11 say what ADR 0009 changed (the Scoop manifest and the file it names come from one server; the copy of release assets to that server is manual and outside the attested pipeline). |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T4: a proxy named in a folder's own git configuration, and its own `http.followRedirects`, are refused like a URL rewrite, so the folder does not choose the server a fetch is made through or sent on to (the token was not exposed by the proxy: TLS is end to end). T3: a repository whose name is git's own folder name, in any spelling Windows opens as that folder, fails its row in the sync on every platform, not only on Windows, and is refused by the WSL clone (#184). |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 added with the Gitea listers of the engine (ADR 0010, #202): a server at an address the user names is a new component and a new place a token is sent to. The rules that keep the token on that server are stated there. Accounts, the CLI and the app do not use a Gitea server yet. |
| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); accounts on another forge are in `accounts-forges.json`, which an older gclo does not rewrite; their token is bound to the account and its server address. T13 restated for it. |
+525
View File
@@ -0,0 +1,525 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using gclo.Engine;
using gclo.ViewModels;
namespace gclo.Cli.Tests;
/// <summary>
/// The command line and forges: which place a run names, which token goes there, and
/// what an account may and may not be changed to. The rules that matter most are
/// about tokens: the one in the environment for one forge is never used for another,
/// and an account's token goes only where the account was added for.
/// </summary>
public sealed class ForgeCliTests : IDisposable
{
private const string Server = "https://git.example.test/";
private readonly string _dir =
Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly string? _gitHubBefore = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
private readonly string? _giteaBefore = Environment.GetEnvironmentVariable(TokenOptions.GiteaVariable);
private readonly List<ForgeEndpoint> _asked = [];
private readonly FakeRepoLister _repos = new() { Result = [new RepoDescriptor("tool", "https://x/tool.git", "main", false)] };
private readonly FakeOrgLister _orgs = new() { Result = ["me", "acme"] };
private AccountsStore? _store;
public ForgeCliTests() => Tokens(gitHub: "github-token", gitea: "gitea-token");
public void Dispose()
{
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, _gitHubBefore);
Environment.SetEnvironmentVariable(TokenOptions.GiteaVariable, _giteaBefore);
try
{
Directory.Delete(_dir, recursive: true);
}
catch(IOException)
{
}
}
private static void Tokens(string? gitHub, string? gitea)
{
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, gitHub);
Environment.SetEnvironmentVariable(TokenOptions.GiteaVariable, gitea);
}
private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
private Func<IActivityLog, (AccountsStore, ITokenVault)> Open => _ => (Store, _vault);
private IRepositoryLister ReposOf(ForgeEndpoint endpoint)
{
_asked.Add(endpoint);
return _repos;
}
private IOrganizationLister OrgsOf(ForgeEndpoint endpoint)
{
_asked.Add(endpoint);
return _orgs;
}
private Task<int> Repos(params string[] args) => ReposCommand.RunAsync(args, ReposOf, new NullLog(), CancellationToken.None);
private Task<int> Orgs(params string[] args) => OrgsCommand.RunAsync(args, OrgsOf, new NullLog(), CancellationToken.None);
private Task<int> Sync(params string[] args)
=> SyncCommand.RunAsync(args, ReposOf, new FakeGit(), Open, new NullLog(), CancellationToken.None);
private int Accounts(params string[] args) => AccountsCommand.Run(args, Open, new NullLog());
private Account AddGiteaAccount(string name = "forge")
{
using var console = new ConsoleCapture();
Assert.Equal(0, Accounts("add", "--name", name, "--org", "acme", "--target", Path.Combine(_dir, "target"), "--server", "git.example.test"));
return Store.FindByName(name)!;
}
// ---------------------------------------------------------------- which place, which token
[Theory]
[InlineData("--forge", "gitea", "--server", "git.example.test")]
[InlineData("--server", Server)] // a server alone means a Gitea server
[InlineData("--forge", "GITEA", "--server", "https://GIT.example.test")]
public async Task AGiteaRun_AsksThatServer_WithTheGiteaToken_NeverTheGitHubOne(params string[] forge)
{
using var console = new ConsoleCapture();
int code = await Repos(["--org", "acme", .. forge]);
Assert.Equal(0, code);
ForgeEndpoint asked = Assert.Single(_asked);
Assert.Equal(ForgeKind.Gitea, asked.Kind);
Assert.Equal(Server, asked.Server!.AbsoluteUri);
Assert.Equal("gitea-token", _repos.LastToken);
}
[Theory]
[InlineData]
[InlineData("--forge", "github")]
[InlineData("--forge", "GitHub")]
public async Task AGitHubRun_AsksGitHub_WithTheGitHubToken_NeverTheGiteaOne(params string[] forge)
{
using var console = new ConsoleCapture();
int code = await Repos(["--org", "acme", .. forge]);
Assert.Equal(0, code);
Assert.Same(ForgeEndpoint.GitHub, Assert.Single(_asked));
Assert.Equal("github-token", _repos.LastToken);
}
[Fact]
public async Task WithNoGiteaTokenInTheEnvironment_AGiteaRunFails_AndDoesNotFallBackToTheGitHubToken()
{
Tokens(gitHub: "github-token", gitea: null);
CliErrorException repos = await Assert.ThrowsAsync<CliErrorException>(() => Repos("--org", "acme", "--server", Server));
CliErrorException orgs = await Assert.ThrowsAsync<CliErrorException>(() => Orgs("--server", Server));
CliErrorException sync = await Assert.ThrowsAsync<CliErrorException>(
() => Sync("--org", "acme", "--target", _dir, "--server", Server, "--dry-run"));
Assert.All([repos, orgs, sync], error => Assert.Contains("'GITEA_TOKEN' is not set", error.Message, StringComparison.Ordinal));
Assert.Null(_repos.LastToken);
}
[Fact]
public async Task WithNoGitHubTokenInTheEnvironment_AGitHubRunFails_AndDoesNotFallBackToTheGiteaToken()
{
Tokens(gitHub: null, gitea: "gitea-token");
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Repos("--org", "acme"));
Assert.Contains("'GITHUB_TOKEN' is not set", error.Message, StringComparison.Ordinal);
Assert.Null(_repos.LastToken);
}
[Theory]
[InlineData("--forge takes github or gitea. (The address of a server goes after --server.)", "--forge", "gitlab")]
[InlineData(
"--forge takes github or gitea. (The address of a server goes after --server.)",
"--forge", "https://someone:s3cret-token@git.example.test")] // an address after the wrong option: not repeated either
[InlineData("A Gitea account needs the address of its server.", "--forge", "gitea")]
[InlineData("GitHub has no server address to choose: it is always github.com.", "--forge", "github", "--server", Server)]
[InlineData("The server address cannot be used: it has to begin with https://.", "--server", "http://git.example.test")]
[InlineData(
"The server address cannot be used: it contains a user name or a password; the token is entered separately.",
"--server", "https://someone:s3cret-token@git.example.test")] // not repeated: the secret is in it
public async Task WhatNamesNoPlace_IsAUsageError_BeforeAnythingIsAsked(string said, params string[] forge)
{
CliUsageException repos = await Assert.ThrowsAsync<CliUsageException>(() => Repos(["--org", "acme", .. forge]));
CliUsageException orgs = await Assert.ThrowsAsync<CliUsageException>(() => Orgs(forge));
CliUsageException sync = await Assert.ThrowsAsync<CliUsageException>(() => Sync(["--org", "acme", "--target", _dir, .. forge]));
CliUsageException add = Assert.Throws<CliUsageException>(
() => Accounts(["add", "--name", "n", "--org", "acme", "--target", _dir, .. forge]));
Assert.All([repos, orgs, sync, add], error => Assert.Equal(said, error.Message));
Assert.Empty(_asked);
}
[Fact]
public async Task Orgs_OfAGiteaServer_AreAskedThere_WithTheGiteaToken()
{
using var console = new ConsoleCapture();
int code = await Orgs("--server", Server);
Assert.Equal(0, code);
Assert.Equal(ForgeKind.Gitea, Assert.Single(_asked).Kind);
Assert.Contains("acme", console.Out, StringComparison.Ordinal);
}
[Fact]
public async Task Sync_OfAGiteaServer_ListsThere()
{
using var console = new ConsoleCapture();
int code = await Sync("--org", "acme", "--target", Path.Combine(_dir, "t"), "--forge", "gitea", "--server", Server, "--dry-run");
Assert.Equal(0, code);
Assert.Equal(Server, Assert.Single(_asked).Server!.AbsoluteUri);
Assert.Equal("gitea-token", _repos.LastToken);
Assert.Contains("tool", console.Out, StringComparison.Ordinal);
}
[Theory]
[InlineData(ForgeAccessKind.Unauthorized, ExitCodes.Auth)]
[InlineData(ForgeAccessKind.Forbidden, ExitCodes.Auth)]
[InlineData(ForgeAccessKind.RateLimited, ExitCodes.Transient)]
[InlineData(ForgeAccessKind.Unavailable, ExitCodes.Transient)]
[InlineData(ForgeAccessKind.NotFound, ExitCodes.Fatal)]
public async Task AFailureOfAnyForge_HasTheExitCodeOfItsKind(ForgeAccessKind kind, int exitCode)
{
_repos.Throw = new ForgeAccessException(kind, "what the server said");
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Repos("--org", "acme", "--server", Server));
Assert.Equal(exitCode, error.ExitCode);
Assert.Equal(exitCode, ExitCodes.ForAccess(kind));
Assert.Equal("what the server said", error.Message);
}
[Fact]
public void EachForge_HasItsOwnDefaultTokenVariable()
{
Assert.Equal("GITHUB_TOKEN", TokenOptions.DefaultVariableFor(ForgeKind.GitHub));
Assert.Equal("GITEA_TOKEN", TokenOptions.DefaultVariableFor(ForgeKind.Gitea));
}
// ---------------------------------------------------------------- accounts
[Fact]
public void AccountsAdd_ForAGiteaServer_SavesTheForgeAndTheNormalizedServer_AndTheGiteaTokenInItsOwnEntry()
{
using var console = new ConsoleCapture();
int code = Accounts("add", "--name", "forge", "--org", "acme", "--target", Path.Combine(_dir, "target"), "--server", "git.example.test");
Assert.Equal(0, code);
Assert.Contains("Account 'forge' added on git.example.test.", console.Out, StringComparison.Ordinal);
Account account = Store.FindByName("forge")!;
Assert.Equal(ForgeKind.Gitea, account.Forge);
Assert.Equal(Server, account.Server);
Assert.Equal("gitea-token", _vault.TryRetrieve(account.TokenEntryId));
Assert.Null(_vault.TryRetrieve(account.Id)); // where a gclo that knows only GitHub would look
}
[Fact]
public void AccountsAdd_ForGitHub_IsAsBefore()
{
using var console = new ConsoleCapture();
int code = Accounts("add", "--name", "work", "--org", "acme", "--target", Path.Combine(_dir, "target"));
Assert.Equal(0, code);
Assert.Contains("Account 'work' added.", console.Out, StringComparison.Ordinal);
Account account = Store.FindByName("work")!;
Assert.Equal(ForgeKind.GitHub, account.Forge);
Assert.Null(account.Server);
Assert.Equal("github-token", _vault.TryRetrieve(account.Id));
}
[Fact]
public void AccountsAdd_OnTheDefaultToken_IsForGitHubOnly()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
CliUsageException error = Assert.Throws<CliUsageException>(
() => Accounts("add", "--name", "forge", "--org", "acme", "--target", _dir, "--server", Server, "--use-default-token"));
Assert.Equal(
"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on git.example.test a token of its own.",
error.Message);
Assert.Empty(Store.GetAll());
}
[Fact]
public void AccountsList_SaysWhereEachAccountLives()
{
AddGiteaAccount();
Store.Save(new Account { Id = Guid.NewGuid(), Name = "work", Organization = "acme", TargetRoot = _dir }, "ghp");
using var console = new ConsoleCapture();
Assert.Equal(0, Accounts());
Assert.Equal(0, Accounts("--json"));
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Contains(lines, line => line.StartsWith("forge", StringComparison.Ordinal) && line.Contains(" git.example.test ", StringComparison.Ordinal));
Assert.Contains(lines, line => line.StartsWith("work", StringComparison.Ordinal) && line.Contains(" GitHub ", StringComparison.Ordinal));
Assert.Contains("\"forge\":\"gitea\",\"server\":\"https://git.example.test/\"", console.Out, StringComparison.Ordinal);
Assert.Contains("\"forge\":\"github\",\"server\":null", console.Out, StringComparison.Ordinal);
}
[Theory]
[InlineData("--forge", "github")]
[InlineData("--server", "https://elsewhere.example.test")]
public void AccountsEdit_CannotMoveAnAccountToAnotherPlace(string option, string value)
{
Account before = AddGiteaAccount();
CliUsageException error = Assert.Throws<CliUsageException>(() => Accounts("edit", "--name", "forge", option, value));
Assert.Contains("cannot be changed: its token was saved for that place", error.Message, StringComparison.Ordinal);
Assert.Equal(before, Store.FindByName("forge"));
}
[Fact]
public void AccountsEdit_AGiteaAccount_KeepsATokenOfItsOwn_AndATokenOptionReplacesIt()
{
Account account = AddGiteaAccount();
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Environment.SetEnvironmentVariable("GCLO_FORGE_TEST_TOKEN", "replaced");
using var console = new ConsoleCapture();
try
{
CliErrorException error = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "forge", "--use-default-token"));
Assert.Equal(
"Account 'forge' is on git.example.test: the default token is a GitHub token, so it keeps a token of its own.",
error.Message);
Assert.Equal(0, Accounts("edit", "--name", "forge", "--token-env", "GCLO_FORGE_TEST_TOKEN"));
}
finally
{
Environment.SetEnvironmentVariable("GCLO_FORGE_TEST_TOKEN", null);
}
Assert.Equal("replaced", _vault.TryRetrieve(account.TokenEntryId));
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.Equal(TokenSource.Own, Store.FindByName("forge")!.TokenSource);
}
// ---------------------------------------------------------------- sync --account
[Fact]
public async Task SyncOfAnAccount_GoesWhereTheAccountWasAddedFor_WithItsOwnToken()
{
AddGiteaAccount();
Tokens(gitHub: "github-token", gitea: "another-gitea-token"); // neither is the account's
using var console = new ConsoleCapture();
int code = await Sync("--account", "forge", "--dry-run");
Assert.Equal(0, code);
ForgeEndpoint asked = Assert.Single(_asked);
Assert.Equal(Server, asked.Server!.AbsoluteUri);
Assert.Equal("gitea-token", _repos.LastToken); // the one that was saved with the account
}
[Theory]
[InlineData("--forge", "github")]
[InlineData("--server", "https://elsewhere.example.test")]
public async Task SyncOfAnAccount_TakesNoOtherPlaceFromTheCommandLine(string option, string value)
{
AddGiteaAccount();
CliUsageException error = await Assert.ThrowsAsync<CliUsageException>(() => Sync("--account", "forge", option, value, "--dry-run"));
Assert.Equal("--forge and --server cannot be used with --account: the account says where it lives.", error.Message);
Assert.Empty(_asked);
Assert.Null(_repos.LastToken);
}
[Fact]
public async Task AnAccountThatCannotBeUsedAsItIsSaved_IsNotSynced_AndIsListedAsSuch()
{
// A file edited by hand: the server over plain http.
Account account = AddGiteaAccount();
string file = Path.Combine(_dir, "accounts-forges.json");
File.WriteAllText(file, File.ReadAllText(file).Replace(Server, "http://git.example.test/", StringComparison.Ordinal));
_store = null; // read the file again
using var console = new ConsoleCapture();
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Sync("--account", "forge", "--dry-run"));
Assert.Equal(0, Accounts());
Assert.Equal(
"Account 'forge' cannot be used as it is saved: the server address cannot be used: "
+ "it has to begin with https://. Remove the account and add it again.",
error.Message);
Assert.Empty(_asked);
Assert.Null(_repos.LastToken);
Assert.Contains(" gitea (not usable) ", console.Out, StringComparison.Ordinal);
Assert.Equal("gitea-token", _vault.TryRetrieve(account.TokenEntryId)); // untouched, and sent nowhere
}
[Fact]
public void TheJsonListing_GivesTheAddressATokenGoesTo_AndNeverOneThatWasRefused()
{
// A file edited by hand: a password in the address of one account, a forge of a later version on another.
AddGiteaAccount("edited");
AddGiteaAccount("forge");
AddGiteaAccount("later");
string file = Path.Combine(_dir, "accounts-forges.json");
string[] parts = File.ReadAllText(file).Split("\"Server\": \"https://git.example.test/\"");
Assert.Equal(4, parts.Length);
File.WriteAllText(
file,
parts[0] + "\"Server\": \"https://bot:hunter2@git.example.test/\""
+ parts[1] + "\"Server\": \"https://git.example.test/\""
+ parts[2].Replace("\"Forge\": \"Gitea\"", "\"Forge\": \"GitLab\"", StringComparison.Ordinal) + "\"Server\": \"https://git.example.test/\""
+ parts[3]);
_store = null; // read the file again
using var console = new ConsoleCapture();
Assert.Equal(0, Accounts("--json"));
using var listed = JsonDocument.Parse(console.Out);
var byName = listed.RootElement.EnumerateArray().ToDictionary(a => a.GetProperty("name").GetString()!, StringComparer.Ordinal);
Assert.DoesNotContain("hunter2", console.Out, StringComparison.Ordinal);
Assert.Equal("gitea", byName["edited"].GetProperty("forge").GetString());
Assert.Equal(JsonValueKind.Null, byName["edited"].GetProperty("server").ValueKind);
Assert.Equal("gitea", byName["forge"].GetProperty("forge").GetString());
Assert.Equal("https://git.example.test/", byName["forge"].GetProperty("server").GetString());
Assert.Equal("unknown", byName["later"].GetProperty("forge").GetString());
Assert.Equal(JsonValueKind.Null, byName["later"].GetProperty("server").ValueKind);
}
[Fact]
public async Task AnAccountOnAForgeThisVersionDoesNotKnow_IsNotSynced_AndIsToldWhichVersionToUse()
{
AddGiteaAccount();
string file = Path.Combine(_dir, "accounts-forges.json");
File.WriteAllText(file, File.ReadAllText(file).Replace("\"Forge\": \"Gitea\"", "\"Forge\": \"GitLab\"", StringComparison.Ordinal));
_store = null; // read the file again
using var console = new ConsoleCapture();
CliErrorException error = await Assert.ThrowsAsync<CliErrorException>(() => Sync("--account", "forge", "--dry-run"));
Assert.Equal(0, Accounts());
Assert.Equal(
"Account 'forge' cannot be used as it is saved: it is on a forge this version of gclo does not know. "
+ "Use the version of gclo that added it.",
error.Message);
Assert.Empty(_asked);
Assert.Null(_repos.LastToken);
Assert.Contains(" unknown (not usable) ", console.Out, StringComparison.Ordinal);
}
[Fact]
public async Task TwoAccountsOfOneName_AreNotChosenBetween()
{
// An older gclo does not see the accounts on another forge: it can add a GitHub account with the name of one.
Account forge = AddGiteaAccount("platform");
File.WriteAllText(
Path.Combine(_dir, "accounts.json"),
$$"""[{ "Id": "{{Guid.NewGuid()}}", "Name": "Platform", "Organization": "octo", "TargetRoot": "C:\\repos" }]""");
_store = null; // read the files again
using var console = new ConsoleCapture();
const string Said = "Two accounts are named 'Platform', so the name does not say which one is meant. "
+ "Give one of them another name in the gclo desktop app (Edit account), then try again.";
CliErrorException sync = await Assert.ThrowsAsync<CliErrorException>(() => Sync("--account", "platform", "--dry-run"));
CliErrorException edit = Assert.Throws<CliErrorException>(() => Accounts("edit", "--name", "platform", "--parallel", "2"));
CliErrorException remove = Assert.Throws<CliErrorException>(() => Accounts("remove", "--name", "platform"));
Assert.All([sync, edit, remove], error => Assert.Equal(Said, error.Message));
Assert.All([sync, edit, remove], error => Assert.Equal(ExitCodes.Fatal, error.ExitCode)); // 2, as docs/CLI.md says
Assert.Empty(_asked);
Assert.Null(_repos.LastToken);
Assert.Equal(2, Store.GetAll().Count);
Assert.Equal("gitea-token", _vault.TryRetrieve(forge.TokenEntryId));
}
}
+7 -3
View File
@@ -9,8 +9,10 @@ namespace gclo.Cli;
/// <summary>
/// One saved account in the JSON listing of 'gclo accounts --json'.
/// <see cref="LastSync"/> is the UTC time of the last completed sync, or null
/// when the account has never synced; <see cref="Id"/> is the key of its
/// Credential Manager entry ('gclo:account:&lt;id&gt;').
/// when the account has never synced; <see cref="Id"/> is the account's id (for a
/// GitHub account also the key of its Credential Manager entry,
/// 'gclo:account:&lt;id&gt;'). <see cref="Forge"/> is 'github' or 'gitea';
/// <see cref="Server"/> is the address of a Gitea account's server, null for GitHub.
/// </summary>
internal sealed record AccountSummary(
string Id,
@@ -22,4 +24,6 @@ internal sealed record AccountSummary(
int MaxConcurrency,
DateTimeOffset? LastSync,
string? LastSyncSummary,
string TokenSource);
string TokenSource,
string Forge,
string? Server);
+99 -17
View File
@@ -30,25 +30,33 @@ internal static class AccountsCommand
gclo accounts edit --name <name> [options] [token option]
gclo accounts remove --name <name>
An account pairs sync settings (organization, target root, parallelism,
org-subfolder preference, description) with a token stored in Windows
Credential Manager. Use one with 'gclo sync --account <name>'. Because the
token lives in Windows Credential Manager, accounts only work on Windows.
An account pairs sync settings (organization, where it lives, target root,
parallelism, org-subfolder preference, description) with a token stored in
Windows Credential Manager. Use one with 'gclo sync --account <name>'.
Because the token lives in Windows Credential Manager, accounts only work
on Windows.
list (default)
Prints the accounts, one per line, in aligned columns: name,
organization, target root, token source ('own' or 'default'), and last
sync time (local time, 'never' when the account has not completed a
sync yet).
organization, where it lives ('GitHub', or the host of a Gitea server),
target root, token source ('own' or 'default'), and last sync time
(local time, 'never' when the account has not completed a sync yet).
--json Print a single-line JSON array instead:
[{"id":"...","name":"...","description":"...","organization":"...",
"targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N,
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null,
"tokenSource":"own"|"default"}]
"tokenSource":"own"|"default","forge":"github"|"gitea",
"server":"https://git.example.com/"|null}]
add
--name <name> Display name; must be unique (case-insensitive).
--org <name> GitHub organization or user account to sync.
--org <name> Organization or user account to sync.
--forge <kind> Where the organization lives: github (the default)
or gitea (a Gitea or Forgejo server; needs --server).
--server <address> The address of the Gitea server, for example
https://git.example.com. HTTPS only: it is where
the token is sent. Implies --forge gitea. Both are
fixed once the account is added.
--target <folder> Target root folder. A relative folder is stored as
a full path, resolved against the current directory.
--parallel <N> Parallel clone/pull count, 1-64 (default 8).
@@ -56,11 +64,14 @@ internal static class AccountsCommand
--description <text> Optional note.
--token-env <VAR> | --token-file <path> | --token-stdin
The token to store (default: the GITHUB_TOKEN
environment variable). Never pass it as an argument.
environment variable, or GITEA_TOKEN for a Gitea
server). Never pass it as an argument.
--use-default-token Instead of a token of its own, the account uses
the default token saved in the desktop app's
Settings, read whenever it syncs, so replacing
the default token there updates this account too.
GitHub accounts only: the default token is a
GitHub token.
edit
--name <name> The account to change (required).
@@ -75,6 +86,9 @@ internal static class AccountsCommand
without a token option the token is left untouched.
--use-default-token Switch the account to the default token; its own
token is removed from Windows Credential Manager.
GitHub accounts only.
The forge and the server of an account cannot be changed: its token was
saved for that place. Remove the account and add it again.
remove
--name <name> Deletes the account's settings and its stored
@@ -155,7 +169,8 @@ internal static class AccountsCommand
IReadOnlyList<AccountSummary> summaries = accounts
.Select(a => new AccountSummary(
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a)))
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a),
ForgeLabel(a), ServerOf(a)))
.ToList();
Terminal.OutVerbatim(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
@@ -171,11 +186,12 @@ internal static class AccountsCommand
int nameWidth = accounts.Max(a => a.Name.Length);
int orgWidth = accounts.Max(a => a.Organization.Length);
int whereWidth = accounts.Max(a => Where(a).Length);
int targetWidth = accounts.Max(a => a.TargetRoot.Length);
foreach(Account account in accounts)
{
Terminal.Out(
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} {Where(account).PadRight(whereWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}");
}
return ExitCodes.Success;
@@ -207,11 +223,12 @@ internal static class AccountsCommand
int parallel = AppSettings.DefaultConcurrency;
bool orgSubfolder = false;
var tokenOptions = new TokenOptions();
var forgeOptions = new ForgeOptions();
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
if(tokenOptions.TryConsume(reader) || forgeOptions.TryConsume(reader))
{
continue;
}
@@ -258,13 +275,19 @@ internal static class AccountsCommand
}
string targetRoot = AbsoluteTarget(target);
ForgeEndpoint endpoint = forgeOptions.Resolve();
if(tokenOptions.UseDefault && endpoint.Kind != ForgeKind.GitHub)
{
throw new CliUsageException(
$"--use-default-token is for GitHub accounts: the default token is a GitHub token. Give the account on {endpoint} a token of its own.");
}
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}");
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', forge={endpoint}, token={(tokenOptions.UseDefault ? "default" : "own")}");
try
{
// The token (if the account gets one of its own) is read before the store
// opens, so a bad token option fails without touching accounts.json.
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve();
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve(endpoint.Kind);
(AccountsStore store, ITokenVault vault) = open(log);
RequireDefaultTokenIfUsed(tokenOptions, vault);
var account = new Account
@@ -277,9 +300,14 @@ internal static class AccountsCommand
CreateOrgSubfolder = orgSubfolder,
MaxConcurrency = parallel,
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
Forge = endpoint.Kind,
Server = endpoint.Server?.AbsoluteUri,
};
SaveOrFail(store, account, token);
Terminal.Out($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
Terminal.Out(
$"Account '{account.Name}' added"
+ (endpoint.Kind == ForgeKind.GitHub ? "" : $" on {endpoint}")
+ (tokenOptions.UseDefault ? " (uses the default token)." : "."));
return ExitCodes.Success;
}
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
@@ -343,6 +371,10 @@ internal static class AccountsCommand
reader.RejectValue();
orgSubfolder = true;
break;
case "--forge" or "--server":
throw new CliUsageException(
"The forge and the server of an account cannot be changed: its token was saved for that place. "
+ "Remove the account and add it again.");
case "--no-org-subfolder":
reader.RejectValue();
orgSubfolder = false;
@@ -373,8 +405,14 @@ internal static class AccountsCommand
{
(AccountsStore store, ITokenVault vault) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
RefuseASharedName(store, existing.Name);
if(tokenOptions.UseDefault && existing.Forge != ForgeKind.GitHub)
{
throw new CliErrorException(
$"Account '{existing.Name}' is on {Where(existing)}: the default token is a GitHub token, so it keeps a token of its own.");
}
RequireDefaultTokenIfUsed(tokenOptions, vault);
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve() : null;
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve(existing.Forge) : null;
// A token option decides the source: --use-default-token switches to the
// default token (and drops the own entry); --token-* gives the account a
@@ -445,6 +483,7 @@ internal static class AccountsCommand
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
RefuseASharedName(store, existing.Name);
store.Delete(existing.Id); // the store logs the deletion
Terminal.Out($"Account '{existing.Name}' removed.");
return ExitCodes.Success;
@@ -470,6 +509,31 @@ internal static class AccountsCommand
/// <summary>The forge as the JSON listing names it: 'github', 'gitea', or 'unknown' for one this version does not know.</summary>
private static string ForgeLabel(Account account)
=> !account.IsOnAKnownForge ? "unknown"
: account.Forge == ForgeKind.GitHub ? "github"
: "gitea";
/// <summary>
/// The server of an account as the JSON listing gives it: the address the token
/// is sent to, as it is normalized, and nothing for an account whose stored
/// address cannot be used. What the file says is not repeated as it is: an
/// address that cannot be used may be one with a password or a token in it.
/// </summary>
private static string? ServerOf(Account account)
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.Server?.AbsoluteUri : null;
/// <summary>Where an account lives, as the listing and messages name it: "GitHub", or the host of its server.</summary>
private static string Where(Account account)
=> account.TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.DisplayName : $"{ForgeLabel(account)} (not usable)";
/// <summary>
/// --use-default-token is only useful when a default token exists: without one
/// the account could never sync, so the command fails up front and says where
@@ -560,6 +624,24 @@ internal static class AccountsCommand
/// <summary>
/// Two accounts of one name: the command does not choose one of them. It can
/// only come about through a version of gclo from before forges, which does not
/// see the accounts on another forge and so does not know the name is taken.
/// </summary>
/// <exception cref="CliErrorException">More than one account is named <paramref name="name"/>.</exception>
internal static void RefuseASharedName(AccountsStore store, string name)
{
if(store.IsNameShared(name))
{
throw new CliErrorException(
$"Two accounts are named '{name}', so the name does not say which one is meant. "
+ "Give one of them another name in the gclo desktop app (Edit account), then try again.");
}
}
private static CliErrorException UnknownAccount(string name, AccountsStore store)
{
IReadOnlyList<Account> all = store.GetAll();
+3 -3
View File
@@ -31,12 +31,12 @@ internal sealed class CliErrorException : Exception
/// <summary>
/// Wraps an engine failure: the listers translate GitHub refusals into
/// <see cref="GitHubAccessException"/>, whose kind picks the exit code; any other
/// Wraps an engine failure: the listers of every forge translate refusals into
/// <see cref="ForgeAccessException"/>, whose kind picks the exit code; any other
/// <see cref="InvalidOperationException"/> stays fatal (2).
/// </summary>
public static CliErrorException FromEngine(InvalidOperationException ex)
=> new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
=> new(ex.Message, ex, ex is ForgeAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
+4 -4
View File
@@ -31,7 +31,7 @@ internal static class ExitCodes
/// <summary>
/// Not the caller's doing: a rate limit or temporary throttle, a network failure,
/// a timeout, or an error on GitHub's side. Retry later.
/// a timeout, or an error on the server's side (GitHub, or a Gitea server). Retry later.
/// </summary>
public const int Transient = 4;
@@ -41,10 +41,10 @@ internal static class ExitCodes
/// <summary>The exit code for an engine access refusal of the given kind.</summary>
public static int ForAccess(GitHubAccessKind kind) => kind switch
public static int ForAccess(ForgeAccessKind kind) => kind switch
{
GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth,
GitHubAccessKind.RateLimited or GitHubAccessKind.Unavailable => Transient,
ForgeAccessKind.Unauthorized or ForgeAccessKind.Forbidden => Auth,
ForgeAccessKind.RateLimited or ForgeAccessKind.Unavailable => Transient,
_ => Fatal,
};
}
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// Collects and resolves the options that say where an organization lives:
/// <c>--forge github|gitea</c> and, for a Gitea server, <c>--server &lt;address&gt;</c>.
/// Shared by the commands that talk to a forge. Without either, the forge is GitHub;
/// a server alone means a Gitea server, the only kind that has one to name.
/// </summary>
internal sealed class ForgeOptions
{
private string? _forge;
private string? _server;
/// <summary>True when <c>--forge</c> or <c>--server</c> was given.</summary>
public bool WasGiven => _forge is not null || _server is not null;
/// <summary>Consumes the reader's current option when it is one of these.</summary>
public bool TryConsume(OptionReader reader)
{
switch(reader.Current)
{
case "--forge":
_forge = reader.RequireValue();
return true;
case "--server":
_server = reader.RequireValue();
return true;
default:
return false;
}
}
/// <summary>The kind of forge the options name: what <c>--forge</c> says, else Gitea when a server was given, else GitHub.</summary>
/// <exception cref="CliUsageException"><c>--forge</c> names no kind gclo knows.</exception>
public ForgeKind ResolveKind()
{
if(_forge is null)
{
return _server is null ? ForgeKind.GitHub : ForgeKind.Gitea;
}
return _forge.Trim().ToUpperInvariant() switch
{
"GITHUB" => ForgeKind.GitHub,
"GITEA" => ForgeKind.Gitea,
// What was given is not repeated: an address typed after the wrong option may hold a password or a token.
_ => throw new CliUsageException("--forge takes github or gitea. (The address of a server goes after --server.)"),
};
}
/// <summary>The place the options name.</summary>
/// <exception cref="CliUsageException">The options name no usable place (GitHub with a server, Gitea without one, an address that is not HTTPS).</exception>
public ForgeEndpoint Resolve()
=> ForgeEndpoint.TryCreate(ResolveKind(), _server, out ForgeEndpoint? endpoint, out string? reason)
? endpoint
: throw new CliUsageException(char.ToUpperInvariant(reason[0]) + reason[1..] + ".");
}
+27 -11
View File
@@ -18,12 +18,18 @@ internal static class OrgsCommand
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.
organizations (GitHub: a fine-grained PAT, or a classic PAT without read:org;
Gitea: a token without read:organization) still prints its own account login.
Options:
--forge <kind> Where the organization lives: github (the default) or
gitea (a Gitea or Forgejo server; needs --server).
--server <address> The address of the Gitea server, for example
https://git.example.com. HTTPS only: it is where the
token is sent. Implies --forge gitea.
--token-env <VAR> Read the token from environment variable VAR
(default: GITHUB_TOKEN when no token option is given).
(default when no token option is given: GITHUB_TOKEN,
or GITEA_TOKEN for a Gitea server; never the other's).
--token-file <path> Read the token from the first non-blank line of a file.
--token-stdin Read the token as one line from standard input.
--json Print a single-line JSON array instead.
@@ -34,7 +40,7 @@ internal static class OrgsCommand
1 canceled (Ctrl+C)
2 fatal: bad arguments, or missing token
3 the token was rejected (401) or refused access (403)
4 rate limit, network failure, timeout, or an error on GitHub's side; retry later
4 rate limit, network failure, timeout, or an error on the server's side; retry later
Security:
There is deliberately no '--token <value>' option: process command lines
@@ -44,24 +50,33 @@ internal static class OrgsCommand
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
/// <summary>Composition root: wires the real listers of the forge the options name, and the file log.</summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
=> RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
=> RunAsync(
args, endpoint => endpoint.CreateOrganizationLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
/// <summary>The command with one lister, whatever forge the options name.</summary>
internal static Task<int> RunAsync(
string[] args, IOrganizationLister lister, IActivityLog log, CancellationToken cancellationToken)
=> RunAsync(args, _ => lister, log, cancellationToken);
internal static async Task<int> RunAsync(
string[] args, IOrganizationLister lister, IActivityLog log, CancellationToken cancellationToken)
string[] args, Func<ForgeEndpoint, IOrganizationLister> listers, IActivityLog log, CancellationToken cancellationToken)
{
bool json = false;
var tokenOptions = new TokenOptions();
var forgeOptions = new ForgeOptions();
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
if(tokenOptions.TryConsume(reader) || forgeOptions.TryConsume(reader))
{
continue;
}
@@ -79,16 +94,17 @@ internal static class OrgsCommand
}
}
log.Info($"orgs started: json={json}");
ForgeEndpoint endpoint = forgeOptions.Resolve();
log.Info($"orgs started: forge={endpoint}, json={json}");
try
{
string token = tokenOptions.Resolve();
string token = tokenOptions.Resolve(endpoint.Kind);
IReadOnlyList<string> logins;
try
{
logins = await lister
logins = await listers(endpoint)
.ListOrganizationsAsync(token, cancellationToken)
.ConfigureAwait(false);
}
+26 -10
View File
@@ -22,7 +22,12 @@ internal static class ReposCommand
selection before syncing, or to feed a script.
Options:
--org <name> GitHub organization or user account (required).
--org <name> Organization or user account (required).
--forge <kind> Where the organization lives: github (the default) or
gitea (a Gitea or Forgejo server; needs --server).
--server <address> The address of the Gitea server, for example
https://git.example.com. HTTPS only: it is where the
token is sent. Implies --forge gitea.
--include <glob> Only repositories whose name matches the pattern
('*' and '?' wildcards, case-insensitive). Repeatable;
a repository matches when any include matches.
@@ -30,7 +35,8 @@ internal static class ReposCommand
applied after --include.
--skip-archived Drop archived repositories.
--token-env <VAR> Read the token from environment variable VAR
(default: GITHUB_TOKEN when no token option is given).
(default when no token option is given: GITHUB_TOKEN,
or GITEA_TOKEN for a Gitea server; never the other's).
--token-file <path> Read the token from the first non-blank line of a file.
--token-stdin Read the token as one line from standard input.
--json Print a single-line JSON array instead:
@@ -42,31 +48,40 @@ internal static class ReposCommand
1 canceled (Ctrl+C)
2 fatal: bad arguments, missing token, organization not found
3 the token was rejected or may not see the organization
4 rate limit, network failure, timeout, or an error on GitHub's side; retry later
4 rate limit, network failure, timeout, or an error on the server's side; retry later
""";
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
/// <summary>Composition root: wires the real listers of the forge the options name, and the file log.</summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
=> RunAsync(args, new GitHubRepositoryLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
=> RunAsync(
args, endpoint => endpoint.CreateRepositoryLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
/// <summary>The command with one lister, whatever forge the options name.</summary>
internal static Task<int> RunAsync(
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
=> RunAsync(args, _ => lister, log, cancellationToken);
internal static async Task<int> RunAsync(
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
string[] args, Func<ForgeEndpoint, IRepositoryLister> listers, IActivityLog log, CancellationToken cancellationToken)
{
string? org = null;
bool json = false;
var filter = new RepoFilterSpec();
var tokenOptions = new TokenOptions();
var forgeOptions = new ForgeOptions();
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
if(tokenOptions.TryConsume(reader) || forgeOptions.TryConsume(reader))
{
continue;
}
@@ -102,16 +117,17 @@ internal static class ReposCommand
throw new CliUsageException("--org is required.");
}
log.Info($"repos started: org='{org}', filters={filter}, json={json}");
ForgeEndpoint endpoint = forgeOptions.Resolve();
log.Info($"repos started: org='{org}', forge={endpoint}, filters={filter}, json={json}");
try
{
string token = tokenOptions.Resolve();
string token = tokenOptions.Resolve(endpoint.Kind);
IReadOnlyList<RepoDescriptor> repositories;
try
{
repositories = await lister
repositories = await listers(endpoint)
.ListOrganizationRepositoriesAsync(org.Trim(), token, cancellationToken)
.ConfigureAwait(false);
}
+73 -19
View File
@@ -23,15 +23,23 @@ internal static class SyncCommand
fail independently; one failure never stops the rest.
Options:
--org <name> GitHub organization or user account to sync
--org <name> Organization or user account to sync
(required unless --account provides it).
--forge <kind> Where the organization lives: github (the default) or
gitea (a Gitea or Forgejo server; needs --server).
--server <address> The address of the Gitea server, for example
https://git.example.com. HTTPS only: it is where the
token is sent. Implies --forge gitea.
Not with --account: the account says where it
lives.
--target <folder> Local root folder; each repository becomes a subfolder
(required unless --account provides it; created if
missing).
--account <name> Use a saved account (see 'gclo accounts'; Windows
only): seeds --org, --target, and --parallel from
the account and reads the token from Windows
Credential Manager. When the account opts into an
the account, syncs from the forge and server the
account was added for, and reads the token from
Windows Credential Manager. When the account opts into an
organization subfolder the target becomes
<targetRoot>\<org>. Explicit options override the
account's values; a token option overrides the
@@ -55,7 +63,8 @@ internal static class SyncCommand
listed on stderr and in the activity log, and the
repository counts as cloned.
--token-env <VAR> Read the token from environment variable VAR
(default: GITHUB_TOKEN when no token option is given).
(default when no token option is given: GITHUB_TOKEN,
or GITEA_TOKEN for a Gitea server; never the other's).
--token-file <path> Read the token from the first non-blank line of a file.
--token-stdin Read the token as one line from standard input.
--json Print no progress lines; end with one line of JSON:
@@ -93,7 +102,7 @@ internal static class SyncCommand
3 the token was rejected (401) or may not see the organization (403):
fix the token, do not retry as is
4 not your doing (rate limit, network failure, timeout, or an error on
GitHub's side): retry later
the server's side): retry later
70 an unexpected error inside gclo; see the activity log
With --json or --json-lines, exits 2-70 print no document on stdout.
@@ -129,6 +138,8 @@ internal static class SyncCommand
public RepoFilterSpec Filter { get; } = new();
public TokenOptions TokenOptions { get; } = new();
public ForgeOptions Forge { get; } = new();
}
@@ -143,7 +154,7 @@ internal static class SyncCommand
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(options.TokenOptions.TryConsume(reader))
if(options.TokenOptions.TryConsume(reader) || options.Forge.TryConsume(reader))
{
continue;
}
@@ -250,20 +261,33 @@ internal static class SyncCommand
/// <summary>Composition root: wires the real GitHub lister, LibGit2 client, and file log.</summary>
/// <summary>Composition root: wires the real listers of the forge the run names, the LibGit2 client, and the file log.</summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Wires the real network/native collaborators; delegates to the covered core.")]
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
=> RunAsync(
args,
new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory),
endpoint => endpoint.CreateRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open,
new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory),
cancellationToken);
/// <summary>The command with one lister, whatever forge the run names.</summary>
internal static Task<int> RunAsync(
string[] args,
IRepositoryLister lister,
IGitClient git,
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts,
IActivityLog log,
CancellationToken cancellationToken)
=> RunAsync(args, _ => lister, git, openAccounts, log, cancellationToken);
internal static async Task<int> RunAsync(
string[] args,
IRepositoryLister lister,
Func<ForgeEndpoint, IRepositoryLister> listers,
IGitClient git,
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts,
IActivityLog log,
@@ -291,16 +315,14 @@ internal static class SyncCommand
{
// --account: seed org/target/parallel from the saved account. Explicit
// options given alongside win because ??= only fills what is still unset.
Account? account = null;
AccountsStore? store = null;
ITokenVault? vault = null;
if(accountName is not null)
(Account? account, AccountsStore? store, ITokenVault? vault, ForgeEndpoint endpoint) = WhereFrom(options, openAccounts, log);
if(account is not null)
{
(account, store, vault) = OpenAccount(openAccounts, accountName, log);
org ??= account.Organization;
target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot;
parallel ??= account.MaxConcurrency;
}
IRepositoryLister lister = listers(endpoint);
if(string.IsNullOrWhiteSpace(org))
{
@@ -314,11 +336,11 @@ internal static class SyncCommand
string organization = org.Trim();
string targetRoot = target.Trim();
log.Info($"sync started: org='{organization}', target='{targetRoot}', parallel={effectiveParallel}, "
log.Info($"sync started: org='{organization}', forge={endpoint}, target='{targetRoot}', parallel={effectiveParallel}, "
+ $"filters={filter}, dryRun={dryRun}, sanitizePaths={sanitizePaths}, json={json}, jsonLines={jsonLines}, quiet={quiet}"
+ (account is null ? "" : $", account='{account.Name}'"));
string token = ResolveToken(tokenOptions, account, vault);
string token = ResolveToken(tokenOptions, account, vault, endpoint.Kind);
// List first (the desktop app's list-select-sync shape): filters and
// --dry-run need the descriptors before anything runs, and the engine's
@@ -480,6 +502,37 @@ internal static class SyncCommand
/// <summary>
/// The account a run is for, when it is for one, and the place it syncs from: the
/// forge and server the account was added for, or what <c>--forge</c> and
/// <c>--server</c> name.
/// </summary>
private static (Account? Account, AccountsStore? Store, ITokenVault? Vault, ForgeEndpoint Endpoint) WhereFrom(
SyncOptions options, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts, IActivityLog log)
{
if(options.AccountName is null)
{
return (null, null, null, options.Forge.Resolve());
}
///////////////////////////////////////////////////////////////////////////
// An account's token belongs to the forge and server the account was //
// added for. Nothing on the command line sends it elsewhere. //
///////////////////////////////////////////////////////////////////////////
if(options.Forge.WasGiven)
{
throw new CliUsageException("--forge and --server cannot be used with --account: the account says where it lives.");
}
(Account account, AccountsStore store, ITokenVault vault) = OpenAccount(openAccounts, options.AccountName, log);
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? unusable)
? (account, store, vault, endpoint)
: throw new CliErrorException(
$"Account '{account.Name}' cannot be used as it is saved: {unusable}. {account.AdviceWhenUnusable("Remove")}");
}
/// <summary>
/// Opens the accounts and finds the one named. A failure of the accounts file or
/// of the credential store is a fatal error with its reason (or "retry later"
@@ -492,6 +545,7 @@ internal static class SyncCommand
{
(AccountsStore store, ITokenVault vault) = openAccounts(log);
Account account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store);
AccountsCommand.RefuseASharedName(store, account.Name);
return (account, store, vault);
}
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
@@ -505,13 +559,13 @@ internal static class SyncCommand
/// <summary>
/// The token for this run. Any explicit token option wins; otherwise a saved
/// account's token comes from Windows Credential Manager; plain runs fall back
/// to the default environment variable.
/// to the default environment variable of the forge.
/// </summary>
private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault)
private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault, ForgeKind forge)
{
if(account is null || tokenOptions.HasExplicitSource)
{
return tokenOptions.Resolve();
return tokenOptions.Resolve(forge);
}
// vault is non-null whenever account is: both come from AccountsCommand.Open.
+22 -6
View File
@@ -3,11 +3,14 @@
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// Collects and resolves the GitHub token source options shared by all commands.
/// Collects and resolves the token source options shared by all commands.
/// There is deliberately no plain '--token &lt;value&gt;' 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
@@ -16,9 +19,12 @@ namespace gclo.Cli;
/// </summary>
internal sealed class TokenOptions
{
/// <summary>Environment variable consulted when no token option is given.</summary>
/// <summary>Environment variable consulted for GitHub when no token option is given.</summary>
public const string DefaultVariable = "GITHUB_TOKEN";
/// <summary>Environment variable consulted for a Gitea server when no token option is given.</summary>
public const string GiteaVariable = "GITEA_TOKEN";
private string? _envVariable;
private string? _filePath;
@@ -86,11 +92,21 @@ internal sealed class TokenOptions
/// <summary>
/// Resolves the token from the single configured source, or from the
/// <see cref="DefaultVariable"/> environment variable when none was given.
/// The environment variable that holds the token for <paramref name="forge"/> when
/// no token option is given. Each forge has its own: a token that is in the
/// environment for GitHub is never sent to a Gitea server because no option was
/// given, nor the other way round.
/// </summary>
public static string DefaultVariableFor(ForgeKind forge) => forge == ForgeKind.GitHub ? DefaultVariable : GiteaVariable;
/// <summary>
/// Resolves the token from the single configured source, or from the default
/// environment variable of <paramref name="forge"/> when none was given.
/// Throws with an actionable message when the token is missing or empty.
/// </summary>
public string Resolve()
public string Resolve(ForgeKind forge = ForgeKind.GitHub)
{
EnsureAtMostOneSource();
if(_useDefault)
@@ -107,7 +123,7 @@ internal sealed class TokenOptions
{
return FromFile(_filePath);
}
return FromEnvironment(_envVariable ?? DefaultVariable, wasExplicit: _envVariable is not null);
return FromEnvironment(_envVariable ?? DefaultVariableFor(forge), wasExplicit: _envVariable is not null);
}
+756
View File
@@ -0,0 +1,756 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Nodes;
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Where accounts are kept on disk now that an account can be on another forge
/// (#204): GitHub accounts in accounts.json as ever, the others in a file of their
/// own. The tests are built around what goes wrong otherwise: a version of gclo from
/// before forges rewrites accounts.json with what it knows, and an account whose
/// forge and server it dropped would read as a GitHub account from then on. They
/// also cover what this version does with what it does not know itself (a forge or
/// a field of a later version), and that a token is found only by the account and
/// the place it was saved for.
/// </summary>
public sealed class AccountFilesTests : IDisposable
{
private const string Server = "https://git.example.test/";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly RecordingActivityLog _log = new();
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private AccountsStore NewStore() => new(_vault, _root, _log);
private string GitHubFile => Path.Combine(_root, "accounts.json");
private string ForgesFile => Path.Combine(_root, "accounts-forges.json");
private static Account GitHubAccount(string name = "Work") => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
private static Account GiteaAccount(string name = "Forge", string? server = Server)
=> GitHubAccount(name) with { Forge = ForgeKind.Gitea, Server = server };
/// <summary>One account as a file states it, with whatever else <paramref name="more"/> says about it.</summary>
private static string Record(Account account, string more = "")
=> $$"""
{
"Id": "{{account.Id}}",
"Name": "{{account.Name}}",
"Organization": "{{account.Organization}}",
"TargetRoot": "C:\\repos"{{(more.Length == 0 ? "" : ",\n " + more)}}
}
""";
private void WriteFile(string path, params string[] records)
{
Directory.CreateDirectory(_root);
File.WriteAllText(path, "[\n" + string.Join(",\n", records) + "\n]");
}
/// <summary>
/// What a gclo from before forges does to accounts.json when it records a sync
/// result: it reads the accounts with the fields it knows and writes them back.
/// </summary>
private void RewriteAsAVersionFromBeforeForgesDoes()
{
string[] known =
[
"Id", "TokenSource", "Name", "Description", "Organization", "TargetRoot", "CreateOrgSubfolder", "MaxConcurrency",
"LastSyncUtc", "LastSyncSummary",
];
JsonArray accounts = JsonNode.Parse(File.ReadAllText(GitHubFile))!.AsArray();
var rewritten = new JsonArray();
foreach(JsonNode? account in accounts)
{
var kept = new JsonObject();
foreach(string field in known)
{
if(account![field] is JsonNode value)
{
kept[field] = value.DeepClone();
}
}
kept["LastSyncSummary"] = "synced by the older version";
rewritten.Add(kept);
}
File.WriteAllText(GitHubFile, rewritten.ToJsonString());
}
// ---------------------------------------------------------------- two files
[Fact]
public void AnAccountOnAnotherForge_IsKeptInAFileOfItsOwn_AndAGitHubAccountWhereItAlwaysWas()
{
AccountsStore store = NewStore();
store.Save(GiteaAccount(), "gitea_token");
// Nothing was put where an older version reads and rewrites.
Assert.False(File.Exists(GitHubFile));
Assert.Contains("\"Forge\": \"Gitea\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
store.Save(GitHubAccount(), "ghp_token");
string gitHub = File.ReadAllText(GitHubFile);
Assert.Contains("\"Name\": \"Work\"", gitHub, StringComparison.Ordinal);
Assert.DoesNotContain("Forge", gitHub, StringComparison.Ordinal);
Assert.DoesNotContain("Server", gitHub, StringComparison.Ordinal);
Assert.DoesNotContain("\"Name\": \"Work\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void AnOlderVersionThatRewritesAccountsJson_LeavesAnAccountOnAnotherForgeAsItIs()
{
Account work = GitHubAccount();
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(work, "ghp_token");
store.Save(forge, "gitea_token");
RewriteAsAVersionFromBeforeForgesDoes();
AccountsStore after = NewStore();
Account stillThere = Assert.Single(after.GetAll(), a => a.Id == forge.Id);
Assert.Equal(ForgeKind.Gitea, stillThere.Forge);
Assert.Equal(Server, stillThere.Server);
Assert.IsType<GiteaRepositoryLister>(AccountListers.RepositoryListerFor(stillThere));
Assert.Equal("gitea_token", new AccountTokenResolver(_vault).TryResolve(stillThere));
// And what the older version wrote about the GitHub account is there too.
Assert.Equal("synced by the older version", after.FindByName("Work")!.LastSyncSummary);
Assert.Equal("ghp_token", new AccountTokenResolver(_vault).TryResolve(after.FindByName("Work")!));
}
[Fact]
public void AChange_WritesOnlyTheFileOfTheAccountItChanges()
{
Account work = GitHubAccount();
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(work, "ghp_token");
store.Save(forge, "gitea_token");
string gitHubBefore = File.ReadAllText(GitHubFile);
string forgesBefore = File.ReadAllText(ForgesFile);
// A backup is written each time a file is replaced: none so far, each file was written once.
Assert.False(File.Exists(GitHubFile + ".bak"));
Assert.False(File.Exists(ForgesFile + ".bak"));
store.RecordSyncResult(forge.Id, DateTimeOffset.UtcNow, "3 synced");
Assert.Equal(gitHubBefore, File.ReadAllText(GitHubFile));
Assert.False(File.Exists(GitHubFile + ".bak"));
Assert.True(File.Exists(ForgesFile + ".bak"));
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
string forgesAfterItsChange = File.ReadAllText(ForgesFile);
store.Save(work with { Description = "edited" }, token: null);
Assert.Equal(forgesAfterItsChange, File.ReadAllText(ForgesFile));
Assert.NotEqual(forgesBefore, forgesAfterItsChange, StringComparer.Ordinal);
Assert.True(File.Exists(GitHubFile + ".bak"));
}
[Fact]
public void DeletingTheLastAccountOnAnotherForge_LeavesItsFileEmpty_AndTheOtherFileAlone()
{
Account work = GitHubAccount();
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(work, "ghp_token");
store.Save(forge, "gitea_token");
store.Delete(forge.Id);
Assert.Equal("[]", File.ReadAllText(ForgesFile).Trim());
Assert.Null(_vault.TryRetrieve(forge.TokenEntryId));
Assert.Equal("Work", Assert.Single(NewStore().GetAll()).Name);
}
[Fact]
public void AnAccountOnAnotherForge_FoundInAccountsJson_IsMovedToItsOwnFile_ByTheNextChange()
{
// gclo does not put it there; a person can.
Account work = GitHubAccount();
Account forge = GiteaAccount();
WriteFile(GitHubFile, Record(work), Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
AccountsStore store = NewStore();
Assert.Equal(ForgeKind.Gitea, store.FindByName("Forge")!.Forge);
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
Assert.DoesNotContain("Gitea", File.ReadAllText(GitHubFile), StringComparison.Ordinal);
Assert.Contains("\"Forge\": \"Gitea\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
Assert.DoesNotContain(_log.Messages, m => m.Contains("missing or damaged", StringComparison.Ordinal)); // nothing was
}
[Fact]
public void AnAccountThatIsInBothFiles_IsTheOneOfItsOwnFile()
{
// The copy in accounts.json is what an older version could have made of it: a GitHub account.
Account forge = GiteaAccount();
WriteFile(GitHubFile, Record(forge));
WriteFile(ForgesFile, Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
Account read = Assert.Single(NewStore().GetAll());
Assert.Equal(ForgeKind.Gitea, read.Forge);
Assert.Equal(Server, read.Server);
}
[Fact]
public void ADamagedFileOfOneKind_IsMovedAside_AndTheAccountsOfTheOtherAreStillThere()
{
NewStore().Save(GitHubAccount(), "ghp_token");
File.WriteAllText(ForgesFile, "{ this is not a list of accounts");
AccountsStore store = NewStore();
Assert.Equal("Work", Assert.Single(store.GetAll()).Name);
Assert.False(File.Exists(ForgesFile));
Assert.Single(Directory.GetFiles(_root, "accounts-forges.json.corrupt-*"));
Assert.Empty(Directory.GetFiles(_root, "accounts.json.corrupt-*"));
}
[Fact]
public void AFileOfOneKindThatWentMissing_IsWrittenBack_ByAChangeToTheOther()
{
Account work = GitHubAccount();
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(work, "ghp_token");
store.Save(forge, "gitea_token");
File.Delete(ForgesFile);
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
Assert.Contains(
_log.Messages,
m => m.Contains("accounts-forges.json' is missing or damaged; the change is applied to the 1 account(s)", StringComparison.Ordinal));
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void AFileOfOneKindThatCannotBeRead_HidesItsAccountsOnly_AndSaysWhichFile()
{
Account work = GitHubAccount();
NewStore().Save(work, "ghp_token");
NewStore().Save(GiteaAccount(), "gitea_token");
using(new FileStream(ForgesFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
var store = new AccountsStore(_vault, _root, _log, TimeSpan.FromMilliseconds(50));
Assert.Equal("Work", Assert.Single(store.GetAll()).Name);
Assert.Contains(
_log.Messages,
m => m.Contains("accounts-forges.json'", StringComparison.Ordinal) && m.Contains("its accounts are not shown", StringComparison.Ordinal));
}
}
// ---------------------------------------------------------------- what this version does not know
[Fact]
public void AForgeThisVersionDoesNotKnow_MakesThatAccountUnusable_NotTheFile_AndIsWrittenBackAsItWas()
{
Account later = GitHubAccount("Later");
Account forge = GiteaAccount();
WriteFile(
ForgesFile,
Record(later, "\"Forge\": \"GitLab\", \"Server\": \"https://gitlab.example.test/\""),
Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
AccountsStore store = NewStore();
Account read = store.FindByName("Later")!;
Assert.Equal(2, store.GetAll().Count);
Assert.Empty(Directory.GetFiles(_root, "*.corrupt-*"));
Assert.False(read.IsOnAKnownForge);
Assert.NotEqual(ForgeKind.GitHub, read.Forge); // nothing takes it for a GitHub account
Assert.NotEqual(ForgeKind.Gitea, read.Forge);
Assert.False(read.TryGetEndpoint(out _, out string? reason));
Assert.Equal("it is on a forge this version of gclo does not know", reason);
Assert.Equal("a forge this version of gclo does not know", read.DescribePlace());
Assert.NotEqual(read.Id, read.TokenEntryId);
// The default token is a GitHub token, and this is not a GitHub account.
Assert.Null(new AccountTokenResolver(_vault).TryResolve(read with { TokenSource = TokenSource.Default }));
// A change to another account writes the file: what it says about this one stays.
store.RecordSyncResult(forge.Id, DateTimeOffset.UtcNow, "3 synced");
string file = File.ReadAllText(ForgesFile);
Assert.Contains("\"Forge\": \"GitLab\"", file, StringComparison.Ordinal);
Assert.Contains("\"Server\": \"https://gitlab.example.test/\"", file, StringComparison.Ordinal);
}
[Fact]
public async Task AnAccountOnAForgeThisVersionDoesNotKnow_AsksNoServer_AndSaysWhichVersionToUse()
{
Account later = GitHubAccount("Later") with { ForgeName = "GitLab", Server = "https://gitlab.example.test/" };
ForgeAccessException error = await Assert.ThrowsAsync<ForgeAccessException>(
() => AccountListers.RepositoryListerFor(later).ListOrganizationRepositoriesAsync("acme", "a-token"));
Assert.Equal(
"Account 'Later' cannot be used as it is saved: it is on a forge this version of gclo does not know. "
+ "Use the version of gclo that added it.",
error.Message);
}
[Theory]
[InlineData(null, ForgeKind.GitHub)] // GitHub is the account that names no forge
[InlineData("Gitea", ForgeKind.Gitea)]
[InlineData("gitea", ForgeKind.Gitea)]
[InlineData("GITEA", ForgeKind.Gitea)]
public void TheNameOfAForge_IsReadWhateverItsCase(string? stored, ForgeKind kind)
{
Account account = GitHubAccount() with { ForgeName = stored };
Assert.Equal(kind, account.Forge);
Assert.True(account.IsOnAKnownForge);
}
[Theory]
[InlineData("")]
[InlineData("1")] // not a number either: a kind is named
[InlineData("Forgejo")]
[InlineData("GitHub")] // a GitHub account names no forge: one that says it is on GitHub was not written by gclo
public void ANameThatIsNoForgeThisVersionKnows_IsNoKindAtAll(string stored)
{
Account account = GitHubAccount() with { ForgeName = stored };
Assert.False(account.IsOnAKnownForge);
Assert.False(Enum.IsDefined(account.Forge));
Assert.NotEqual(account.Id, account.TokenEntryId);
Assert.IsType<UnusableAccountLister>(AccountListers.RepositoryListerFor(account));
}
[Fact]
public void ARecordInTheFileForOtherForges_ThatNamesNoForge_IsNotAGitHubAccount_AndStaysThere()
{
// Written by hand: gclo writes no such record. Read as a GitHub account it would get GitHub's listers and the default token.
Account odd = GitHubAccount("Odd");
Account work = GitHubAccount();
WriteFile(ForgesFile, Record(odd, $"\"forge\": \"Gitea\", \"Server\": \"{Server}\"")); // the key in another case: not the field
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
_vault.Store(odd.Id, "a token under its id");
AccountsStore store = NewStore();
Account read = Assert.Single(store.GetAll());
Assert.False(read.IsOnAKnownForge);
Assert.IsType<UnusableAccountLister>(AccountListers.OrganizationListerFor(read));
var resolver = new AccountTokenResolver(_vault);
Assert.Null(resolver.TryResolve(read));
Assert.Null(resolver.TryResolve(read with { TokenSource = TokenSource.Default }));
store.Save(work, "ghp_token"); // a change to another account
Assert.DoesNotContain("Odd", File.ReadAllText(GitHubFile), StringComparison.Ordinal);
Assert.Contains("\"Name\": \"Odd\"", File.ReadAllText(ForgesFile), StringComparison.Ordinal);
Assert.False(NewStore().FindByName("Odd")!.IsOnAKnownForge);
}
[Fact]
public void TheKindOfForge_IsStoredByItsName_AndGitHubAsNothing()
{
Assert.Null((GitHubAccount() with { Forge = ForgeKind.GitHub }).ForgeName);
Assert.Equal("Gitea", (GitHubAccount() with { Forge = ForgeKind.Gitea }).ForgeName);
Assert.Null((GiteaAccount() with { Forge = ForgeKind.GitHub }).ForgeName);
}
[Fact]
public void WhatALaterVersionSaysAboutAnAccount_IsWrittenBackAsItWas()
{
Account work = GitHubAccount();
WriteFile(GitHubFile, Record(work, "\"Colour\": \"blue\", \"Schedule\": { \"EveryHours\": 6, \"Days\": [\"mon\", \"thu\"] }"));
AccountsStore store = NewStore();
store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced");
store.Save(store.FindByName("Work")! with { Description = "edited here" }, token: null);
JsonNode written = JsonNode.Parse(File.ReadAllText(GitHubFile))![0]!;
Assert.Equal("edited here", (string?)written["Description"]);
Assert.Equal("5 synced", (string?)written["LastSyncSummary"]);
Assert.Equal("blue", (string?)written["Colour"]);
Assert.Equal(6, (int?)written["Schedule"]!["EveryHours"]);
Assert.Equal("thu", (string?)written["Schedule"]!["Days"]![1]);
}
[Fact]
public async Task WhatALaterVersionSaysAboutAnAccount_SurvivesAnEditInTheWizard_WhichBuildsTheRecordAnew()
{
Account work = GitHubAccount();
WriteFile(GitHubFile, Record(work, "\"Colour\": \"blue\""));
_vault.Store(work.Id, "ghp_token");
AccountsStore store = NewStore();
Account existing = store.FindByName("Work")!;
var wizard = new AccountWizardViewModel(store, new FakeOrganizationLister(), new AppSettings(), existing, () => "ghp_token");
wizard.Description = "edited in the wizard";
while(wizard.Step < 4)
{
Assert.True(await wizard.TryAdvanceAsync(), $"stopped at step {wizard.Step}");
}
await wizard.SaveAsync();
JsonNode written = JsonNode.Parse(File.ReadAllText(GitHubFile))![0]!;
Assert.Equal("edited in the wizard", (string?)written["Description"]);
Assert.Equal("blue", (string?)written["Colour"]);
}
// ---------------------------------------------------------------- a token belongs to one account at one place
[Fact]
public void TheTokenEntry_IsTheAccountsIdForGitHub_AndForAnotherForgeMadeOfTheIdTheForgeAndTheServer()
{
Account gitHub = GitHubAccount();
var zero = new Account { Id = Guid.Empty, Name = "z", Organization = "o", TargetRoot = "t", Forge = ForgeKind.Gitea, Server = Server };
Assert.Equal(gitHub.Id, gitHub.TokenEntryId);
// Pinned: tokens that are saved are found again by this value.
Assert.Equal(new Guid("3b9b1ee2-9b5a-24bc-b069-daf504cf4a82"), zero.TokenEntryId);
// Another account, another forge name, another server: another entry each.
Assert.NotEqual(zero.TokenEntryId, (zero with { Id = Guid.NewGuid() }).TokenEntryId);
Assert.NotEqual(zero.TokenEntryId, (zero with { ForgeName = "GitLab" }).TokenEntryId);
Assert.NotEqual(zero.TokenEntryId, (zero with { Server = "https://elsewhere.example.test/" }).TokenEntryId);
Assert.NotEqual(zero.TokenEntryId, (zero with { Server = null }).TokenEntryId);
}
[Fact]
public void AServerChangedInTheFile_DoesNotGetTheTokenThatWasSavedForTheOther()
{
Account forge = GiteaAccount();
NewStore().Save(forge, "gitea_token");
File.WriteAllText(
ForgesFile, File.ReadAllText(ForgesFile).Replace(Server, "https://elsewhere.example.test/", StringComparison.Ordinal));
Account edited = Assert.Single(NewStore().GetAll());
var resolver = new AccountTokenResolver(_vault);
Assert.Equal("https://elsewhere.example.test/", edited.Server);
Assert.True(edited.TryGetEndpoint(out _, out _)); // a usable address, and no token for it
Assert.Null(resolver.TryResolve(edited));
Assert.Equal("gitea_token", _vault.TryRetrieve(forge.TokenEntryId)); // where it was saved, for the place it was saved for
}
[Fact]
public void ARecordThatNamesAnotherAccountsId_FindsNoTokenOfThatAccount()
{
Account work = GitHubAccount();
NewStore().Save(work, "ghp_token");
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
var resolver = new AccountTokenResolver(_vault);
Account sameId = work with { Forge = ForgeKind.Gitea, Server = "https://elsewhere.example.test/" };
Account asDefault = GitHubAccount() with { Id = AppSettings.DefaultTokenVaultId, Forge = ForgeKind.Gitea, Server = "https://elsewhere.example.test/" };
Assert.Null(resolver.TryResolve(sameId));
Assert.Null(resolver.TryResolve(asDefault));
}
[Fact]
public void AServerSpelledAnotherWayInTheFile_CanStillBeEdited_AndStaysAsItIsSpelled()
{
Account forge = GiteaAccount();
WriteFile(ForgesFile, Record(forge, "\"Forge\": \"gitea\", \"Server\": \"https://GIT.example.test\""));
AccountsStore store = NewStore();
store.Save(store.FindByName("Forge")! with { Description = "edited" }, token: null);
store.Save(store.FindByName("Forge")! with { Server = Server, Forge = ForgeKind.Gitea, MaxConcurrency = 3 }, "gitea_token");
Account saved = Assert.Single(NewStore().GetAll());
Assert.Equal("edited", saved.Description);
Assert.Equal(3, saved.MaxConcurrency);
// To the letter: the token is found by what the file says about the place.
Assert.Equal("gitea", saved.ForgeName);
Assert.Equal("https://GIT.example.test", saved.Server);
Assert.Equal("gitea_token", new AccountTokenResolver(_vault).TryResolve(saved));
}
// ---------------------------------------------------------------- the vault is put back under the same entry
[Fact]
public void ANewTokenForAnAccountOnAnotherForge_WhoseFileCannotBeWritten_PutsThePreviousTokenBack()
{
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(forge, "first");
Directory.CreateDirectory(ForgesFile + ".tmp"); // the next write of that file fails before it changes anything
Assert.Throws<UnauthorizedAccessException>(() => store.Save(forge with { Description = "edited" }, "second"));
Assert.Equal("first", _vault.TryRetrieve(forge.TokenEntryId));
Assert.Null(_vault.TryRetrieve(forge.Id));
Assert.Equal("", Assert.Single(store.GetAll()).Description);
}
[Fact]
public void ANewAccountOnAnotherForge_WhoseFileCannotBeWritten_LeavesNoTokenBehind()
{
Account forge = GiteaAccount();
AccountsStore store = NewStore();
Directory.CreateDirectory(_root);
Directory.CreateDirectory(ForgesFile + ".tmp");
Assert.Throws<UnauthorizedAccessException>(() => store.Save(forge, "gitea_token"));
Assert.Null(_vault.TryRetrieve(forge.TokenEntryId));
Assert.Null(_vault.TryRetrieve(forge.Id));
Assert.Empty(store.GetAll());
}
[Fact]
public void DeletingAnAccountOnAnotherForge_WhoseFileCannotBeWritten_PutsItsTokenBack()
{
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(forge, "gitea_token");
Directory.CreateDirectory(ForgesFile + ".tmp");
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(forge.Id));
Assert.Equal("gitea_token", _vault.TryRetrieve(forge.TokenEntryId));
Assert.Null(_vault.TryRetrieve(forge.Id));
Assert.Single(store.GetAll());
}
// ---------------------------------------------------------------- what has to be written first
[Fact]
public void AChangeThatFirstHasToPutAFileBack_AndCannot_ChangesNothing_NotTheVaultEither()
{
Account work = GitHubAccount();
Account forge = GiteaAccount();
Account another = GiteaAccount("Another", "https://other.example.test/");
AccountsStore store = NewStore();
store.Save(work, "ghp_token");
store.Save(forge, "gitea_token");
string forgesBefore = File.ReadAllText(ForgesFile);
File.Delete(GitHubFile);
Directory.CreateDirectory(GitHubFile + ".tmp"); // accounts.json cannot be written back
// Neither change concerns accounts.json. Both would have written their own file and then failed on the other.
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(forge.Id));
Assert.Throws<UnauthorizedAccessException>(() => store.Save(another, "another_token"));
Assert.Equal(forgesBefore, File.ReadAllText(ForgesFile));
Assert.Equal("gitea_token", _vault.TryRetrieve(forge.TokenEntryId));
Assert.Null(_vault.TryRetrieve(another.TokenEntryId));
Assert.Equal(["Forge", "Work"], store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
// Once the file can be written, the same change goes through and the accounts are back.
Directory.Delete(GitHubFile + ".tmp");
store.Delete(forge.Id);
Assert.Null(_vault.TryRetrieve(forge.TokenEntryId));
Assert.Equal("Work", Assert.Single(NewStore().GetAll()).Name);
}
[Fact]
public void AnAccountInTheWrongFile_ThatCannotBeMoved_StaysWhereItIs()
{
// The file for other forges is written first: an account is in its own file before it leaves the other.
Account work = GitHubAccount();
Account forge = GiteaAccount();
WriteFile(GitHubFile, Record(work), Record(forge, $"\"Forge\": \"Gitea\", \"Server\": \"{Server}\""));
string before = File.ReadAllText(GitHubFile);
AccountsStore store = NewStore();
Directory.CreateDirectory(ForgesFile + ".tmp"); // its own file cannot be written
Assert.Throws<UnauthorizedAccessException>(() => store.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "5 synced"));
Assert.Equal(before, File.ReadAllText(GitHubFile));
Assert.Equal(["Forge", "Work"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void TwoAccountsOfOneName_AreSaidToShareIt()
{
// An older version does not see the accounts on another forge, and can give a GitHub account the name of one.
Account forge = GiteaAccount("Platform");
NewStore().Save(forge, "gitea_token");
WriteFile(GitHubFile, Record(GitHubAccount("platform")), Record(GitHubAccount("Work")));
AccountsStore store = NewStore();
Assert.True(store.IsNameShared("PLATFORM"));
Assert.False(store.IsNameShared("Work"));
Assert.False(store.IsNameShared("nobody"));
}
// ---------------------------------------------------------------- a workspace's token is the one of the place it was opened for
[Fact]
public void AWorkspace_TakesNoTokenThatWasSavedForAnotherPlaceThanTheOneItWasOpenedFor()
{
// The file named another server when the workspace was opened; it names the account's own again by now.
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(forge, "gitea_token");
Account asItReadAtOpen = forge with { Server = "https://elsewhere.example.test/" };
var listers = new FakeRepositoryLister();
using var workspace = new WorkspaceViewModel(
listers, new FakeGitClient(), new FakeOrganizationLister(), orgLookupDebounce: TimeSpan.FromMilliseconds(1),
account: asItReadAtOpen, tokenVault: _vault, accountsStore: store);
Assert.Equal("", workspace.Token); // nothing was saved for that place
workspace.RefreshAccountToken(); // the store now has the record that names the place the token was saved for
Assert.Equal("", workspace.Token);
Assert.Equal("No token is saved for this account. Choose Edit account… and enter one.", workspace.MissingTokenAdvice);
}
// ---------------------------------------------------------------- a workspace without listers of its own
[Fact]
public async Task AWorkspaceThatWasGivenNoListers_HasThoseOfItsAccount()
{
// An account that cannot be used as it is saved has listers that ask no server
// and say why: that is what answers here, for the organizations and for the
// repositories, and not the listers of GitHub.
Account broken = GiteaAccount("Broken", server: "http://git.example.test");
_vault.Store(broken.TokenEntryId, "gitea_token");
const string Said = "Account 'Broken' cannot be used as it is saved: the server address cannot be used: "
+ "it has to begin with https://. Delete the account and add it again.";
using var workspace = new WorkspaceViewModel(
git: new FakeGitClient(), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: _log, account: broken, tokenVault: _vault);
DateTime giveUp = DateTime.UtcNow.AddSeconds(10);
while(!_log.Messages.Any(m => m.StartsWith("Organization lookup failed", StringComparison.Ordinal)) && DateTime.UtcNow < giveUp)
{
await Task.Delay(10);
}
Assert.Contains($"Organization lookup failed: {Said}", _log.Messages, StringComparer.Ordinal);
await workspace.LoadReposCommand.ExecuteAsync(null);
Assert.Equal(Said, workspace.LoadErrorMessage);
}
// ---------------------------------------------------------------- the wizard
[Fact]
public async Task TheWizard_NeverSendsTheDefaultTokenToAnAccountOnAnotherForge_WhateverSetTheChoice()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Account forge = GiteaAccount();
AccountsStore store = NewStore();
store.Save(forge, "gitea_token");
var lister = new FakeOrganizationLister();
var wizard = new AccountWizardViewModel(store, lister, new AppSettings(), forge, () => "gitea_token", defaultToken: () => "ghp_default");
wizard.TokenChoiceIndex = 0; // the choice is not shown for this account; this sets it all the same
Assert.True(await wizard.TryAdvanceAsync());
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("The default token is a GitHub token. This account needs a token of its own.", wizard.TokenError);
Assert.Equal(0, lister.Calls);
}
}
+366
View File
@@ -0,0 +1,366 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// An account's forge and server: how they are stored, where the account's token is
/// kept, and the rules that keep a token on the server it was saved for. Every store
/// uses an <see cref="InMemoryVault"/> and a unique temp directory.
/// </summary>
public sealed class AccountForgeTests : IDisposable
{
private const string Server = "https://git.example.test/";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private AccountsStore NewStore() => new(_vault, _root);
private string FileText => File.ReadAllText(Path.Combine(_root, "accounts.json"));
/// <summary>The file of the accounts that are not on GitHub (see <see cref="AccountFilesTests"/>).</summary>
private string ForgesFileText => File.ReadAllText(Path.Combine(_root, "accounts-forges.json"));
private static Account GitHubAccount(string name = "Work") => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
private static Account GiteaAccount(string name = "Forge", string? server = Server)
=> GitHubAccount(name) with { Forge = ForgeKind.Gitea, Server = server };
// ---------------------------------------------------------------- how it is stored
[Fact]
public void AGitHubAccount_IsWrittenWithoutForgeOrServer_SoOldFilesAndOldVersionsSeeNoDifference()
{
NewStore().Save(GitHubAccount(), "ghp_token");
Assert.DoesNotContain("Forge", FileText, StringComparison.Ordinal);
Assert.DoesNotContain("Server", FileText, StringComparison.Ordinal);
Account reloaded = Assert.Single(NewStore().GetAll());
Assert.Equal(ForgeKind.GitHub, reloaded.Forge);
Assert.Null(reloaded.Server);
Assert.True(reloaded.TryGetEndpoint(out ForgeEndpoint? endpoint, out _));
Assert.Same(ForgeEndpoint.GitHub, endpoint);
}
[Theory]
[InlineData("git.example.test")]
[InlineData("https://GIT.example.test")]
[InlineData(Server)]
public void AGiteaAccount_IsWrittenWithItsForge_AndItsServerAsItWasNormalized(string typed)
{
NewStore().Save(GiteaAccount(server: typed), "gitea_token");
Assert.Contains("\"Forge\": \"Gitea\"", ForgesFileText, StringComparison.Ordinal);
Assert.Contains($"\"Server\": \"{Server}\"", ForgesFileText, StringComparison.Ordinal);
Account reloaded = Assert.Single(NewStore().GetAll());
Assert.Equal(ForgeKind.Gitea, reloaded.Forge);
Assert.Equal(Server, reloaded.Server);
Assert.True(reloaded.TryGetEndpoint(out ForgeEndpoint? endpoint, out _));
Assert.Equal("git.example.test", endpoint.DisplayName);
}
// ---------------------------------------------------------------- where its token is kept
[Fact]
public void TheTokenOfAGiteaAccount_IsKeptWhereAVersionThatKnowsOnlyGitHubDoesNotLook()
{
Account account = GiteaAccount();
NewStore().Save(account, "gitea_token");
// A version from before forges reads this account as a GitHub account and
// looks for its token under the account's id: it must find none.
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.NotEqual(account.Id, account.TokenEntryId);
Assert.Equal("gitea_token", _vault.TryRetrieve(account.TokenEntryId));
Assert.Equal("gitea_token", new AccountTokenResolver(_vault).TryResolve(account));
}
[Fact]
public void Deleting_AGiteaAccount_RemovesItsToken()
{
Account account = GiteaAccount();
AccountsStore store = NewStore();
store.Save(account, "gitea_token");
store.Delete(account.Id);
Assert.Null(_vault.TryRetrieve(account.TokenEntryId));
Assert.Empty(NewStore().GetAll());
}
[Fact]
public void ReplacingTheTokenOfAGiteaAccount_ReplacesThatEntry()
{
Account account = GiteaAccount();
AccountsStore store = NewStore();
store.Save(account, "first");
store.Save(account with { Description = "edited" }, "second");
Assert.Equal("second", _vault.TryRetrieve(account.TokenEntryId));
Assert.Null(_vault.TryRetrieve(account.Id));
}
// ---------------------------------------------------------------- the default token is GitHub's
[Fact]
public void TheDefaultToken_IsNeverGivenToAnAccountOnAnotherForge()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
var resolver = new AccountTokenResolver(_vault);
Account gitea = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
Assert.Null(resolver.TryResolve(gitea));
Assert.Contains("the default token, which is a GitHub token", resolver.MissingDescription(gitea), StringComparison.Ordinal);
Assert.Equal("ghp_default", resolver.TryResolve(GitHubAccount() with { TokenSource = TokenSource.Default }));
}
[Fact]
public void AGiteaAccount_ThatHasNoToken_IsToldWhereItsTokenWouldBe()
{
Account account = GiteaAccount();
string missing = new AccountTokenResolver(_vault).MissingDescription(account);
Assert.Contains(account.TokenEntryId.ToString("N"), missing, StringComparison.Ordinal);
Assert.DoesNotContain(account.Id.ToString("N"), missing, StringComparison.Ordinal);
}
[Fact]
public void Save_AGiteaAccountOnTheDefaultToken_IsRefused()
{
Account account = GiteaAccount() with { TokenSource = TokenSource.Default };
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, token: null));
Assert.Contains("is on git.example.test; the default token is a GitHub token", ex.Message, StringComparison.Ordinal);
Assert.Empty(NewStore().GetAll());
}
// ---------------------------------------------------------------- what cannot be saved
[Theory]
[InlineData(ForgeKind.Gitea, null, "a Gitea account needs the address of its server")]
[InlineData(ForgeKind.Gitea, "http://git.example.test", "it has to begin with https://")]
[InlineData(ForgeKind.Gitea, "https://user:secret@git.example.test", "it contains a user name or a password")]
[InlineData(ForgeKind.GitHub, "https://git.example.test", "GitHub has no server address to choose")]
public void Save_AnAccountWhoseForgeAndServerCannotBeUsed_IsRefused_AndNothingIsStored(ForgeKind forge, string? server, string said)
{
Account account = GitHubAccount() with { Forge = forge, Server = server };
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, "a-token"));
Assert.Contains("cannot be saved", ex.Message, StringComparison.Ordinal);
Assert.Contains(said, ex.Message, StringComparison.Ordinal);
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.Null(_vault.TryRetrieve(account.TokenEntryId));
Assert.Empty(NewStore().GetAll());
}
// ---------------------------------------------------------------- a token stays with its server
[Fact]
public void TheServerOfAnAccount_CannotBeChanged_SoItsTokenIsNotSentSomewhereElse()
{
Account account = GiteaAccount();
AccountsStore store = NewStore();
store.Save(account, "gitea_token");
string before = ForgesFileText;
ArgumentException ex = Assert.Throws<ArgumentException>(
() => store.Save(account with { Server = "https://elsewhere.example.test" }, token: null));
Assert.Contains("is on git.example.test, and that cannot be changed: its token was saved for that place", ex.Message, StringComparison.Ordinal);
Assert.Contains("Delete the account and add it again", ex.Message, StringComparison.Ordinal);
Assert.Equal(before, ForgesFileText);
Assert.Equal("gitea_token", _vault.TryRetrieve(account.TokenEntryId));
}
[Fact]
public void TheForgeOfAnAccount_CannotBeChanged_EvenWithANewToken()
{
Account account = GitHubAccount();
AccountsStore store = NewStore();
store.Save(account, "ghp_token");
ArgumentException ex = Assert.Throws<ArgumentException>(
() => store.Save(account with { Forge = ForgeKind.Gitea, Server = Server }, "gitea_token"));
Assert.Contains("is on GitHub, and that cannot be changed", ex.Message, StringComparison.Ordinal);
Assert.Equal("ghp_token", _vault.TryRetrieve(account.Id));
Assert.Null(_vault.TryRetrieve((account with { Forge = ForgeKind.Gitea }).TokenEntryId));
Assert.Equal(ForgeKind.GitHub, Assert.Single(NewStore().GetAll()).Forge);
}
[Fact]
public void TheSameServer_SpelledAnotherWay_IsNoChange()
{
Account account = GiteaAccount(server: "git.example.test");
AccountsStore store = NewStore();
store.Save(account, "gitea_token");
store.Save(account with { Server = "HTTPS://git.example.test/", Description = "edited" }, token: null);
Account saved = Assert.Single(NewStore().GetAll());
Assert.Equal("edited", saved.Description);
Assert.Equal(Server, saved.Server);
}
[Fact]
public void AnAccountWhoseStoredServerCannotBeUsed_IsNamedAsItIsStored_WhenAChangeIsRefused()
{
// A file edited by hand: the account is on a server over plain http.
Account account = GitHubAccount("Edited");
NewStore().Save(account, "ghp_token");
File.WriteAllText(
Path.Combine(_root, "accounts.json"),
FileText.Replace("\"Name\": \"Edited\"", "\"Forge\": \"Gitea\", \"Server\": \"http://old.example.test\", \"Name\": \"Edited\"", StringComparison.Ordinal));
AccountsStore store = NewStore();
Account onDisk = Assert.Single(store.GetAll());
Assert.False(onDisk.TryGetEndpoint(out _, out string? reason));
Assert.Contains("https://", reason, StringComparison.Ordinal);
ArgumentException ex = Assert.Throws<ArgumentException>(
() => store.Save(onDisk with { Server = Server }, "gitea_token"));
Assert.Contains("is on Gitea, at an address that cannot be used, and that cannot be changed", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("old.example.test", ex.Message, StringComparison.Ordinal); // not repeated: it could hold a password
}
// ---------------------------------------------------------------- the listers of an account
[Fact]
public void AnAccountsListers_AreThoseOfItsForge()
{
Assert.IsType<GitHubRepositoryLister>(AccountListers.RepositoryListerFor(GitHubAccount()));
Assert.IsType<GitHubOrganizationLister>(AccountListers.OrganizationListerFor(GitHubAccount()));
Assert.IsType<GiteaRepositoryLister>(AccountListers.RepositoryListerFor(GiteaAccount()));
Assert.IsType<GiteaOrganizationLister>(AccountListers.OrganizationListerFor(GiteaAccount()));
Assert.Throws<ArgumentNullException>(() => AccountListers.RepositoryListerFor(null!));
Assert.Throws<ArgumentNullException>(() => AccountListers.OrganizationListerFor(null!));
}
[Fact]
public async Task AnAccountThatCannotBeUsedAsItIsSaved_AsksNoServerAnything_AndSaysWhy()
{
Account broken = GiteaAccount("Broken", server: "http://git.example.test");
ForgeAccessException fromRepositories = await Assert.ThrowsAsync<ForgeAccessException>(
() => AccountListers.RepositoryListerFor(broken).ListOrganizationRepositoriesAsync("acme", "a-token"));
ForgeAccessException fromOrganizations = await Assert.ThrowsAsync<ForgeAccessException>(
() => AccountListers.OrganizationListerFor(broken).ListOrganizationsAsync("a-token"));
Assert.Equal(ForgeAccessKind.NotFound, fromRepositories.Kind);
Assert.Equal(
"Account 'Broken' cannot be used as it is saved: the server address cannot be used: "
+ "it has to begin with https://. Delete the account and add it again.",
fromRepositories.Message);
Assert.Equal(fromRepositories.Message, fromOrganizations.Message);
Assert.DoesNotContain("a-token", fromRepositories.Message, StringComparison.Ordinal);
}
// ---------------------------------------------------------------- the wizard
[Fact]
public async Task TheWizard_EditingAGiteaAccount_KeepsItWhereItIs_AndOffersNoDefaultToken()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Account account = GiteaAccount();
AccountsStore store = NewStore();
store.Save(account, "gitea_token");
var wizard = new AccountWizardViewModel(
store, new FakeOrganizationLister(), new AppSettings(), account, () => "gitea_token", defaultToken: () => "ghp_default");
// The default token is a GitHub token: it is not on offer here.
Assert.False(wizard.OffersTokenChoice);
Assert.False(wizard.UseDefaultToken);
wizard.Description = "edited in the wizard";
while(wizard.Step < 4)
{
Assert.True(await wizard.TryAdvanceAsync(), $"stopped at step {wizard.Step}");
}
await wizard.SaveAsync();
Account saved = Assert.Single(NewStore().GetAll());
Assert.Equal("edited in the wizard", saved.Description);
Assert.Equal(ForgeKind.Gitea, saved.Forge);
Assert.Equal(Server, saved.Server);
Assert.Equal("gitea_token", _vault.TryRetrieve(saved.TokenEntryId));
}
[Fact]
public void TheWizard_ForAnAccountOnAnotherForgeThatSaysDefaultToken_DoesNotPreselectIt()
{
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
Account edited = GiteaAccount() with { TokenSource = TokenSource.Default }; // as a file edited by hand could say
var wizard = new AccountWizardViewModel(
NewStore(), new FakeOrganizationLister(), new AppSettings(), edited, () => null, defaultToken: () => "ghp_default");
Assert.False(wizard.UseDefaultToken);
Assert.False(wizard.OffersTokenChoice);
}
}
+85
View File
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// A forge and, where it has one, its server: what may be one, and which listers
/// belong to it.
/// </summary>
public sealed class ForgeEndpointTests
{
[Fact]
public void GitHub_IsOneEndpoint_WithoutAServer_AndWithGitHubsListers()
{
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.GitHub, null, out ForgeEndpoint? endpoint, out string? reason));
Assert.Same(ForgeEndpoint.GitHub, endpoint);
Assert.Null(reason);
Assert.Equal(ForgeKind.GitHub, endpoint.Kind);
Assert.Null(endpoint.Server);
Assert.Equal("GitHub", endpoint.DisplayName);
Assert.Equal("GitHub", endpoint.ToString());
Assert.IsType<GitHubRepositoryLister>(endpoint.CreateRepositoryLister());
Assert.IsType<GitHubOrganizationLister>(endpoint.CreateOrganizationLister());
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.GitHub, " ", out ForgeEndpoint? blank, out _));
Assert.Same(ForgeEndpoint.GitHub, blank);
}
[Theory]
[InlineData("git.example.test", "https://git.example.test/", "git.example.test")]
[InlineData("https://git.example.test:8443/gitea", "https://git.example.test:8443/gitea/", "git.example.test:8443")]
public void AGiteaServer_IsAnEndpointAtItsNormalizedAddress_WithGiteasListers(string typed, string address, string shown)
{
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, typed, out ForgeEndpoint? endpoint, out string? reason));
Assert.Null(reason);
Assert.Equal(ForgeKind.Gitea, endpoint.Kind);
Assert.Equal(address, endpoint.Server!.AbsoluteUri);
Assert.Equal(shown, endpoint.DisplayName);
Assert.Equal(shown, endpoint.ToString());
Assert.IsType<GiteaRepositoryLister>(endpoint.CreateRepositoryLister());
Assert.IsType<GiteaOrganizationLister>(endpoint.CreateOrganizationLister());
}
[Theory]
[InlineData(ForgeKind.GitHub, "https://github.example.test", "GitHub has no server address to choose: it is always github.com")]
[InlineData(ForgeKind.Gitea, null, "a Gitea account needs the address of its server")]
[InlineData(ForgeKind.Gitea, " ", "a Gitea account needs the address of its server")]
[InlineData(ForgeKind.Gitea, " http://git.example.test ", "the server address cannot be used: it has to begin with https://")]
[InlineData(ForgeKind.Gitea, "https://someone:s3cret-token@git.example.test", "the server address cannot be used: it contains a user name or a password; the token is entered separately")]
[InlineData((ForgeKind)99, null, "'99' is not a kind of forge gclo knows")]
public void WhatIsNotAnEndpoint_IsRefused_WithTheReason(ForgeKind kind, string? server, string expected)
{
Assert.False(ForgeEndpoint.TryCreate(kind, server, out ForgeEndpoint? endpoint, out string? reason));
Assert.Null(endpoint);
Assert.Equal(expected, reason);
}
[Fact]
public void Endpoints_AreEqual_WhenTheyAreTheSamePlace()
{
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, "git.example.test", out ForgeEndpoint? typed, out _));
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, "https://GIT.example.test/", out ForgeEndpoint? spelledOut, out _));
Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, "other.example.test", out ForgeEndpoint? other, out _));
Assert.Equal(typed, spelledOut);
Assert.True(typed.Equals((object)spelledOut));
Assert.Equal(typed.GetHashCode(), spelledOut.GetHashCode());
Assert.NotEqual(typed, other);
Assert.NotEqual(typed, ForgeEndpoint.GitHub);
Assert.False(typed.Equals(null));
object text = "git.example.test";
Assert.False(typed.Equals(text));
}
}
+127
View File
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
namespace gclo.Engine;
/// <summary>
/// Where an organization or account lives: the kind of forge and, for a forge that
/// has no one well-known host, the server's address. The one place that says which
/// listers belong to it, so the command line and the desktop app cannot pair an
/// account with the listers of another forge.
/// </summary>
public sealed class ForgeEndpoint : IEquatable<ForgeEndpoint>
{
/// <summary>github.com.</summary>
public static ForgeEndpoint GitHub { get; } = new(ForgeKind.GitHub, null);
/// <summary>The kind of forge.</summary>
public ForgeKind Kind { get; }
/// <summary>The server's base address, as <see cref="GiteaServerUrl"/> normalizes it; null for GitHub.</summary>
public Uri? Server { get; }
/// <summary>How messages and lists name the forge: "GitHub", or the server's host (with its port when that is not 443).</summary>
public string DisplayName => Server?.Authority ?? "GitHub";
private ForgeEndpoint(ForgeKind kind, Uri? server)
{
Kind = kind;
Server = server;
}
/// <summary>
/// The endpoint for a kind of forge and what was given as its server address, or
/// the reason there is none: GitHub takes no address, a Gitea server needs one
/// that <see cref="GiteaServerUrl.TryNormalize"/> accepts.
/// </summary>
/// <param name="kind">The kind of forge.</param>
/// <param name="server">The server's address as it was typed or stored; null or blank for none.</param>
/// <param name="endpoint">The endpoint; null when there is none.</param>
/// <param name="reason">Why there is none; null when there is one.</param>
public static bool TryCreate(
ForgeKind kind, string? server, [NotNullWhen(true)] out ForgeEndpoint? endpoint, [NotNullWhen(false)] out string? reason)
{
endpoint = null;
bool given = !string.IsNullOrWhiteSpace(server);
switch(kind)
{
case ForgeKind.GitHub:
if(given)
{
reason = "GitHub has no server address to choose: it is always github.com";
return false;
}
endpoint = GitHub;
reason = null;
return true;
case ForgeKind.Gitea:
if(!given)
{
reason = "a Gitea account needs the address of its server";
return false;
}
if(!GiteaServerUrl.TryNormalize(server, out Uri? address, out string? whyNot))
{
///////////////////////////////////////////////////////////////////////
// The address is not repeated: it may have been refused for the //
// password or the token that is in it. //
///////////////////////////////////////////////////////////////////////
reason = $"the server address cannot be used: {whyNot}";
return false;
}
endpoint = new ForgeEndpoint(ForgeKind.Gitea, address);
reason = null;
return true;
default:
reason = $"'{kind}' is not a kind of forge gclo knows";
return false;
}
}
/// <summary>The lister of this forge's repositories. For a Gitea server it sends tokens to <see cref="Server"/> only.</summary>
public IRepositoryLister CreateRepositoryLister()
=> Server is null ? new GitHubRepositoryLister() : new GiteaRepositoryLister(Server);
/// <summary>The lister of this forge's organizations. For a Gitea server it sends tokens to <see cref="Server"/> only.</summary>
public IOrganizationLister CreateOrganizationLister()
=> Server is null ? new GitHubOrganizationLister() : new GiteaOrganizationLister(Server);
/// <inheritdoc/>
public bool Equals(ForgeEndpoint? other)
=> other is not null && Kind == other.Kind && Equals(Server, other.Server);
/// <inheritdoc/>
public override bool Equals(object? obj) => Equals(obj as ForgeEndpoint);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(Kind, Server);
/// <summary>The <see cref="DisplayName"/>.</summary>
public override string ToString() => DisplayName;
}
+113 -6
View File
@@ -3,17 +3,20 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// A saved connection profile: which GitHub organization to sync, where its clones
/// live on disk, and how the sync runs. Metadata only — the access token is held
/// separately in an <see cref="ITokenVault"/> (keyed by <see cref="Id"/>, or the
/// default token when <see cref="TokenSource"/> says so) and is never serialized
/// alongside the account.
/// A saved connection profile: which organization to sync (on GitHub, or on a
/// Gitea server), where its clones live on disk, and how the sync runs. Metadata
/// only — the access token is held separately in an <see cref="ITokenVault"/> (keyed
/// by <see cref="TokenEntryId"/>, or the default token when <see cref="TokenSource"/>
/// says so) and is never serialized alongside the account.
/// </summary>
public sealed record Account
{
@@ -34,6 +37,66 @@ public sealed record Account
[JsonIgnore]
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
/// <summary>
/// The kind of forge the account is on, as it is stored: its name, and nothing
/// (null) for GitHub, so a GitHub account is written as it always was. Stored as
/// text and kept as it was read: a name this version does not know (a forge a
/// later version added) makes the one account unusable here, not the file
/// unreadable, and is written back unchanged. Set when the account is added and
/// not changed afterwards: see <see cref="AccountsStore.Save"/>.
/// </summary>
[JsonPropertyName("Forge")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ForgeName { get; init; }
/// <summary>
/// The kind of forge the account is on. GitHub is the account that names no
/// forge, and only that one: it is how every version has written a GitHub
/// account. For a name this version does not know it is none of the kinds
/// (<see cref="IsOnAKnownForge"/> is false): never GitHub, so nothing takes such
/// an account for a GitHub account.
/// </summary>
[JsonIgnore]
public ForgeKind Forge
{
get => ForgeName is null ? ForgeKind.GitHub
: string.Equals(ForgeName, nameof(ForgeKind.Gitea), StringComparison.OrdinalIgnoreCase) ? ForgeKind.Gitea
: s_noKnownForge;
init => ForgeName = value == ForgeKind.GitHub ? null : value.ToString();
}
/// <summary>False when the account names a forge this version of gclo does not know.</summary>
[JsonIgnore]
public bool IsOnAKnownForge => Forge != s_noKnownForge;
/// <summary>
/// The address of the account's server, for a forge that has no one well-known
/// host (Gitea); null for GitHub, and then left out of the file. It is where
/// the account's token is sent, so it is set when the account is added and not
/// changed afterwards.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Server { get; init; }
/// <summary>
/// The vault entry that holds the account's own token: the account's id for a
/// GitHub account, and for any other forge an id made from the account's id, its
/// forge and its server, so a token is found only by the account it was saved
/// for, at the place it was saved for (see <see cref="AccountTokenEntry"/>).
/// </summary>
[JsonIgnore]
public Guid TokenEntryId => AccountTokenEntry.For(this);
/// <summary>
/// What the file says about the account that this version of gclo does not know
/// (a field a later version added). Kept, and written back as it was read, so
/// that changing an account here does not take away what a later version put
/// there. Null when there is nothing of the kind. <see cref="AccountsStore.Save"/>
/// keeps what the file has, whatever the record it is given says here.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? FieldsOfALaterVersion { get; set; }
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
public required string Name { get; init; }
@@ -47,7 +110,7 @@ public sealed record Account
init => field = value ?? "";
} = "";
/// <summary>GitHub organization (or user) whose repositories are synced.</summary>
/// <summary>The organization (or user account) whose repositories are synced.</summary>
public required string Organization { get; init; }
/// <summary>Folder the sync targets; see <see cref="CreateOrgSubfolder"/>.</summary>
@@ -80,4 +143,48 @@ public sealed record Account
/// <summary>One-line outcome of the last sync, or null if it never ran.</summary>
public string? LastSyncSummary { get; init; }
/// <summary>What <see cref="Forge"/> is for a forge name this version does not know: a value that is none of the kinds.</summary>
private static readonly ForgeKind s_noKnownForge = (ForgeKind)(-1);
/// <summary>
/// Where the account lives, or the reason its forge and server cannot be used as
/// they are stored (a file edited by hand, or a forge this version does not know).
/// </summary>
public bool TryGetEndpoint([NotNullWhen(true)] out ForgeEndpoint? endpoint, [NotNullWhen(false)] out string? reason)
{
if(!IsOnAKnownForge)
{
endpoint = null;
reason = "it is on a forge this version of gclo does not know";
return false;
}
return ForgeEndpoint.TryCreate(Forge, Server, out endpoint, out reason);
}
/// <summary>
/// How a message or a listing names the place the account is on: "GitHub", or the
/// host of its server. When what is stored cannot be used, the kind of forge
/// alone: such an address is not repeated, it may hold a password or a token.
/// </summary>
public string DescribePlace()
=> TryGetEndpoint(out ForgeEndpoint? endpoint, out _) ? endpoint.DisplayName
: IsOnAKnownForge ? $"{Forge}, at an address that cannot be used"
: "a forge this version of gclo does not know";
/// <summary>
/// What to do about the account when it cannot be used as it is saved, as the
/// sentence that ends such a message. <paramref name="remove"/> is how the caller
/// names removing an account ("Delete" in the app, "Remove" on the command line).
/// </summary>
public string AdviceWhenUnusable(string remove)
=> IsOnAKnownForge ? $"{remove} the account and add it again." : "Use the version of gclo that added it.";
}
+38
View File
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// The listers that belong to an account: those of its forge and, for a Gitea
/// account, of its server. The desktop app and the command line both ask here, so
/// an account's token is never handed to the listers of another forge.
/// </summary>
public static class AccountListers
{
/// <summary>The lister of the repositories on <paramref name="account"/>'s forge.</summary>
public static IRepositoryLister RepositoryListerFor(Account account)
{
ArgumentNullException.ThrowIfNull(account);
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason)
? endpoint.CreateRepositoryLister()
: new UnusableAccountLister(account, reason);
}
/// <summary>The lister of the organizations on <paramref name="account"/>'s forge.</summary>
public static IOrganizationLister OrganizationListerFor(Account account)
{
ArgumentNullException.ThrowIfNull(account);
return account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason)
? endpoint.CreateOrganizationLister()
: new UnusableAccountLister(account, reason);
}
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Security.Cryptography;
using System.Text;
namespace gclo.ViewModels;
/// <summary>
/// Which vault entry holds an account's own token. For a GitHub account it is the
/// account's id, as it always was. For an account on another forge it is an id
/// made from the account's id, the name of its forge and the address of its
/// server, exactly as the account's file states them.
/// </summary>
/// <remarks>
/// <para>
/// The entry belongs to one account at one place. An account's forge and server
/// cannot be changed through gclo (<see cref="AccountsStore.Save"/>); when the file
/// is changed by hand to name another server, the entry is not found there either,
/// and the account has no token instead of sending the one it had to the new
/// address. A record that names another account's id finds nothing for the same
/// reason.
/// </para>
/// <para>
/// Never to be changed: saved tokens are found by it. What goes in is the text of
/// the file, not an address that was parsed, so a later change to how an address
/// is normalized changes no entry.
/// </para>
/// </remarks>
internal static class AccountTokenEntry
{
/// <summary>The id of the vault entry for the token of <paramref name="account"/>.</summary>
public static Guid For(Account account)
{
if(account.ForgeName is null)
{
return account.Id;
}
byte[] hash = SHA256.HashData(
Encoding.UTF8.GetBytes($"gclo account token\n{account.Id:N}\n{account.ForgeName}\n{account.Server}"));
return new Guid(hash.AsSpan(0, 16));
}
}
+20 -4
View File
@@ -3,6 +3,9 @@
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
@@ -35,9 +38,18 @@ public sealed class AccountTokenResolver
public string? TryResolve(Account account)
{
ArgumentNullException.ThrowIfNull(account);
if(account.TokenSource == TokenSource.Default && account.Forge != ForgeKind.GitHub)
{
///////////////////////////////////////////////////////////////////////////
// The default token is a GitHub token. It is never handed to an account //
// on another forge, however such an account came to say it uses it. //
///////////////////////////////////////////////////////////////////////////
return null;
}
string? token = account.TokenSource == TokenSource.Default
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
: _vault.TryRetrieve(account.Id);
: _vault.TryRetrieve(account.TokenEntryId);
return string.IsNullOrEmpty(token) ? null : token;
}
@@ -57,8 +69,12 @@ public sealed class AccountTokenResolver
public string MissingDescription(Account account)
{
ArgumentNullException.ThrowIfNull(account);
return account.TokenSource == TokenSource.Default
? "uses the default token, but no default token is saved (desktop app: Settings)"
: $"has no token in {_vault.DescribeEntry(account.Id)}";
if(account.TokenSource == TokenSource.Default)
{
return account.Forge == ForgeKind.GitHub
? "uses the default token, but no default token is saved (desktop app: Settings)"
: "is set to use the default token, which is a GitHub token and is not used for another forge; give the account a token of its own";
}
return $"has no token in {_vault.DescribeEntry(account.TokenEntryId)}";
}
}
+22 -2
View File
@@ -131,7 +131,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
// Decided once: the choice is offered only when a default token exists at
// open time, and the value itself is fetched again when a step needs it.
HasDefaultToken = !string.IsNullOrWhiteSpace(defaultToken?.Invoke());
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken && existing.Forge == ForgeKind.GitHub;
Step = 1;
NameError = "";
@@ -215,7 +215,12 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// is shown: when a default token is saved, and also for an account that uses the
/// default token while none is saved, which otherwise could not be given another.
/// </summary>
public bool OffersTokenChoice => HasDefaultToken || _existing?.UsesDefaultToken == true;
/// <remarks>
/// Never for an account that is not on GitHub: the default token is a GitHub
/// token, and such an account has a token of its own.
/// </remarks>
public bool OffersTokenChoice
=> (_existing?.Forge ?? ForgeKind.GitHub) == ForgeKind.GitHub && (HasDefaultToken || _existing?.UsesDefaultToken == true);
/// <summary>
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
@@ -342,6 +347,17 @@ public sealed partial class AccountWizardViewModel : ObservableObject
// An edit with the box left empty validates the STORED token, fetched
// here and used for this one call only.
string candidate;
if(UseDefaultToken && (_existing?.Forge ?? ForgeKind.GitHub) != ForgeKind.GitHub)
{
///////////////////////////////////////////////////////////////////
// The choice is not shown for such an account. Whatever set it, //
// the default token is a GitHub token and is not sent anywhere //
// else. //
///////////////////////////////////////////////////////////////////
TokenError = "The default token is a GitHub token. This account needs a token of its own.";
return false;
}
if(UseDefaultToken)
{
// The default token is fetched for this one call only; the box
@@ -476,6 +492,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
CreateOrgSubfolder = CreateOrgSubfolder,
MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency),
TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own,
// An account stays on the forge and server it was added for: its token was saved for that place.
Forge = _existing?.Forge ?? ForgeKind.GitHub,
Server = _existing?.Server,
LastSyncUtc = _existing?.LastSyncUtc,
LastSyncSummary = _existing?.LastSyncSummary,
};
+288
View File
@@ -0,0 +1,288 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text.Json;
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// One file of accounts on disk, as <see cref="AccountsStore"/> keeps them: how it is
/// read (tolerant of a missing or damaged file, patient with a busy one) and how it
/// is written (in one step, with a backup). It remembers what the file last held,
/// so a file that goes missing or is damaged can be written back, and so a change
/// that does not concern this file leaves it alone.
/// </summary>
/// <remarks>
/// A file that cannot be read is not a corrupt file. Only content that is not valid
/// JSON is moved aside. A file that is in use is tried again for a short while; one
/// that stays unreadable is left exactly where it is, and the read throws.
/// </remarks>
internal sealed class AccountsFile
{
private readonly string _filePath;
private readonly IActivityLog _log;
private readonly TimeSpan _patience;
private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(15);
/// <summary>
/// The file at <paramref name="filePath"/>. <paramref name="patience"/> is how long
/// a file that something else holds for a moment is tried again.
/// </summary>
public AccountsFile(string filePath, IActivityLog log, TimeSpan patience)
{
_filePath = filePath;
_log = log;
_patience = patience;
}
/// <summary>The file's full path.</summary>
public string FilePath => _filePath;
/// <summary>
/// What the file held when it was last read or written. Kept when a later read
/// finds the file missing or damaged: it is what is then written back.
/// </summary>
public IReadOnlyList<Account> Accounts { get; private set; } = [];
/// <summary>Whether the last read found a list of accounts in the file, or the last write put one there.</summary>
public bool IsThere { get; private set; }
private TimeSpan FilePatience => _patience;
/// <summary>
/// Reads the file again. True when it holds a list of accounts, which is then
/// what <see cref="Accounts"/> gives. False when the file is missing or its
/// content is not a list of accounts (it is then moved aside): <see cref="Accounts"/>
/// stays what it was. A file that cannot be read at all throws, and is not touched.
/// </summary>
public bool Refresh()
{
List<Account>? read = ReadFile();
IsThere = read is not null;
if(read is not null)
{
Accounts = read;
}
return IsThere;
}
/// <summary>
/// Writes <paramref name="accounts"/> when the file does not hold exactly that
/// already: a file that is there is rewritten only for a change, and one that is
/// missing or damaged is written when there is an account to put in it.
/// </summary>
public void WriteIfChanged(List<Account> accounts)
{
bool needed = IsThere ? !accounts.SequenceEqual(Accounts) : accounts.Count > 0;
if(needed)
{
Write(accounts);
}
}
/// <summary>
/// The accounts the file holds now, or null when it holds none that can be used:
/// the file is missing, or its content is not a list of accounts (it is then moved
/// aside). A file that cannot be read at all throws, and is not touched.
/// </summary>
private List<Account>? ReadFile()
{
string json;
try
{
json = WithPatience(() => File.ReadAllText(_filePath), FilePatience, accessDeniedIsBusy: false);
}
catch(Exception ex) when(ex is FileNotFoundException or DirectoryNotFoundException)
{
return null;
}
try
{
List<Account>? loaded = JsonSerializer.Deserialize(json, AccountsJsonContext.Default.ListAccount);
if(loaded is not null)
{
return loaded;
}
_log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts.");
}
catch(Exception ex)
{
///////////////////////////////////////////////////////////////////////////
// The text was read; it is what it says that cannot be used. That, and //
// only that, is a corrupt file. //
///////////////////////////////////////////////////////////////////////////
_log.Error($"Accounts file '{_filePath}' is not a valid list of accounts; starting with no accounts.", ex);
}
PreserveCorruptFile();
return null;
}
/// <summary>Runs <paramref name="action"/>, trying again for <paramref name="patience"/> as long as the file is busy.</summary>
internal static T WithPatience<T>(Func<T> action, TimeSpan patience, bool accessDeniedIsBusy)
{
long started = Stopwatch.GetTimestamp();
while(true)
{
try
{
return action();
}
catch(Exception ex) when(IsBusy(ex, accessDeniedIsBusy) && Stopwatch.GetElapsedTime(started) < patience)
{
Thread.Sleep(s_pause);
}
}
}
/// <summary>
/// True for a failure that means "in use right now", which passes: on Windows a
/// sharing or lock violation, and, where the operation reports a busy target that
/// way (replacing a file somebody has open), a refused access. A missing file, a
/// full disk, or a path that is too long is never that, and is not waited for.
/// </summary>
internal static bool IsBusy(Exception exception, bool accessDeniedIsBusy)
{
const int SharingViolation = 32;
const int LockViolation = 33;
if(exception is UnauthorizedAccessException)
{
return accessDeniedIsBusy;
}
return exception is IOException and not (FileNotFoundException or DirectoryNotFoundException or PathTooLongException)
&& (!OperatingSystem.IsWindows() || (exception.HResult & 0xFFFF) is SharingViolation or LockViolation);
}
/// <summary>
/// Moves an unreadable accounts file aside before the empty in-memory list can be
/// persisted over it: the metadata is what associates vault entries with accounts,
/// so clobbering a recoverable file would orphan every stored token.
/// </summary>
private void PreserveCorruptFile()
{
try
{
if(File.Exists(_filePath))
{
string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
File.Move(_filePath, aside, overwrite: true);
_log.Error($"Preserved unreadable accounts file as '{aside}'.");
}
}
catch(Exception ex)
{
_log.Error("Could not preserve the unreadable accounts file.", ex);
}
}
/// <summary>
/// Puts <paramref name="accounts"/> in the file, in one step: the file is never
/// absent and never half written. A failure leaves the file, and what this object
/// holds, as they were.
/// </summary>
private void Write(List<Account> accounts)
{
string? directory = Path.GetDirectoryName(_filePath);
if(!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Atomic replace: a crash or full disk mid-write must never truncate the live
// file — corrupted metadata orphans every vault token (accounts are found by
// Guid, and recreated accounts mint new ones).
string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount);
string tempPath = _filePath + ".tmp";
using(var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using(var writer = new StreamWriter(stream))
{
writer.Write(json);
writer.Flush();
stream.Flush(flushToDisk: true);
}
KeepABackup();
///////////////////////////////////////////////////////////////////////////////
// A file nobody may write to stays that way however long it is tried: say //
// so at once instead of taking the refusal for a file that is busy. //
///////////////////////////////////////////////////////////////////////////////
if(File.Exists(_filePath) && File.GetAttributes(_filePath).HasFlag(FileAttributes.ReadOnly))
{
throw new UnauthorizedAccessException($"The accounts file '{_filePath}' is read-only; nothing was changed.");
}
///////////////////////////////////////////////////////////////////////////////
// One step puts the new file in place of the old: the name is never absent, //
// so a process that reads without the lock always finds a whole file. //
// Something outside gclo (a scanner, a backup, a viewer) can hold the live //
// file for a moment; the step is then refused, and tried again for a while. //
///////////////////////////////////////////////////////////////////////////////
WithPatience(
() =>
{
File.Move(tempPath, _filePath, overwrite: true);
return true;
},
FilePatience,
accessDeniedIsBusy: true);
Accounts = accounts;
IsThere = true;
}
/// <summary>
/// Copies the file that is about to be replaced to a .bak file beside it. Best effort:
/// the backup is a convenience for a person, and failing to write it must not
/// stop the change it is a backup for.
/// </summary>
private void KeepABackup()
{
try
{
if(File.Exists(_filePath))
{
string backup = _filePath + ".bak";
if(File.Exists(backup))
{
File.SetAttributes(backup, FileAttributes.Normal);
}
File.Copy(_filePath, backup, overwrite: true);
}
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
_log.Error($"Could not write the backup '{_filePath}.bak' ({ex.Message}); the change goes ahead without it.", ex);
}
}
}
+185 -217
View File
@@ -3,8 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text.Json;
using gclo.Engine;
@@ -12,10 +10,11 @@ namespace gclo.ViewModels;
/// <summary>
/// Persists account profiles as JSON at accounts.json under
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\KofTwentyTwo\gclo) and their
/// tokens in an <see cref="ITokenVault"/>; the file holds metadata only and never a
/// token. Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
/// Persists account profiles as JSON under <see cref="GcloPaths.DataRoot"/> (default
/// %LOCALAPPDATA%\KofTwentyTwo\gclo) and their tokens in an <see cref="ITokenVault"/>;
/// the files hold metadata only and never a token. GitHub accounts are in
/// accounts.json, as they always were; accounts on another forge are in
/// accounts-forges.json (see the remarks). Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
/// failures in <see cref="Save"/>, <see cref="Delete"/>, and
/// <see cref="RecordSyncResult"/> propagate instead of being swallowed; only loading
/// is tolerant (a missing, corrupt, or unreadable file yields an empty list).
@@ -58,6 +57,27 @@ namespace gclo.ViewModels;
/// that is also what the store holds after a change that failed.
/// </para>
/// <para>
/// <b>Two files.</b> A version of gclo from before forges reads every account in
/// accounts.json as a GitHub account, and writes the file back with what it knows:
/// it would drop the forge and the server of an account on a Gitea server, and from
/// then on that account would read as a GitHub account, whose token is sent to
/// GitHub. The desktop app updates itself and the command line is a separate
/// download, so two versions on one data folder are ordinary. Accounts on another
/// forge are therefore kept in a file of their own, which such a version neither
/// reads nor writes. Both files are changed under the one lock. An account on
/// another forge that is found in accounts.json (gclo does not put one there) is
/// moved to its own file before the next change is applied, and one that is in
/// both files is read from its own. A record in the file for other forges that
/// names no forge is not a GitHub account: it stays there, and cannot be used.
/// </para>
/// <para>
/// A change itself writes one file, the one of the account it changes. Whatever
/// else has to be written (a file that went missing or was damaged, an account
/// that is in the wrong file) is written first, before the vault is touched: when
/// that fails the change fails with nothing changed, and the order and the
/// compensating steps above hold for one file as they always did.
/// </para>
/// <para>
/// A file that cannot be read is not a corrupt file. Only content that is not valid
/// JSON is moved aside. A file that is in use is tried again for a short while; one
/// that stays unreadable is left exactly where it is: at construction the store then
@@ -72,7 +92,14 @@ public sealed class AccountsStore
private readonly IActivityLog _log;
private readonly string _filePath;
/// <summary>accounts.json: the GitHub accounts. Read and written by every version of gclo.</summary>
private readonly AccountsFile _gitHubFile;
/// <summary>accounts-forges.json: the accounts on another forge. A version from before forges does not touch it.</summary>
private readonly AccountsFile _forgesFile;
/// <summary>The lock every gclo process takes before it changes an accounts file.</summary>
private readonly string _lockPath;
/// <summary>How long the lock another gclo process holds is waited for.</summary>
private static readonly TimeSpan s_defaultPatience = TimeSpan.FromSeconds(5);
@@ -85,8 +112,6 @@ public sealed class AccountsStore
/// </summary>
private const int FilePatienceDivisor = 5;
private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(15);
private readonly Lock _gate = new();
private readonly TimeSpan _patience;
@@ -115,8 +140,10 @@ public sealed class AccountsStore
_vault = vault;
_log = log ?? new NullActivityLog();
directory ??= GcloPaths.DataRoot;
_filePath = Path.Combine(directory, "accounts.json");
_patience = patience;
_gitHubFile = new AccountsFile(Path.Combine(directory, "accounts.json"), _log, patience / FilePatienceDivisor);
_forgesFile = new AccountsFile(Path.Combine(directory, "accounts-forges.json"), _log, patience / FilePatienceDivisor);
_lockPath = _gitHubFile.FilePath + ".lock";
_accounts = LoadAtStart();
}
@@ -133,7 +160,7 @@ public sealed class AccountsStore
/// <summary>The account whose name matches case-insensitively, or null.</summary>
/// <summary>The account whose name matches case-insensitively, or null. See <see cref="IsNameShared"/>.</summary>
public Account? FindByName(string name)
{
lock(_gate)
@@ -145,6 +172,23 @@ public sealed class AccountsStore
/// <summary>
/// True when more than one account has the name. <see cref="Save"/> does not let
/// that happen, but a version of gclo from before forges does not see the accounts
/// on another forge and can give a GitHub account the name of one of them. A
/// caller that is given a name to act on asks here first, so it does not act on
/// the wrong one of the two.
/// </summary>
public bool IsNameShared(string name)
{
lock(_gate)
{
return _accounts.Count(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) > 1;
}
}
/// <summary>
/// Inserts or updates (matching by <see cref="Account.Id"/>) and persists
/// immediately, on top of what the file holds at that moment. Throws <see cref="ArgumentException"/> when another account
@@ -171,6 +215,7 @@ public sealed class AccountsStore
RefreshForAChange();
account = ReconcileWithTheFile(account, knownBefore);
account = WithItsForgeChecked(account);
bool nameTaken = _accounts.Any(a =>
a.Id != account.Id
@@ -214,8 +259,8 @@ public sealed class AccountsStore
// Vault first: a failing Store leaves the vault exactly as it was (the
// previous token, if any, is still there), and nothing else has changed.
string? previousToken = _vault.TryRetrieve(account.Id);
_vault.Store(account.Id, token);
string? previousToken = _vault.TryRetrieve(account.TokenEntryId);
_vault.Store(account.TokenEntryId, token);
try
{
@@ -228,11 +273,11 @@ public sealed class AccountsStore
{
if(previousToken is null)
{
_vault.Delete(account.Id);
_vault.Delete(account.TokenEntryId);
}
else
{
_vault.Store(account.Id, previousToken);
_vault.Store(account.TokenEntryId, previousToken);
}
}
catch(Exception compensationFailure)
@@ -266,10 +311,10 @@ public sealed class AccountsStore
/// </summary>
private void SaveUsingDefaultToken(List<Account> updated, Account account, string verb)
{
string? ownToken = _vault.TryRetrieve(account.Id);
string? ownToken = _vault.TryRetrieve(account.TokenEntryId);
if(ownToken is not null)
{
_vault.Delete(account.Id);
_vault.Delete(account.TokenEntryId);
}
try
{
@@ -283,7 +328,7 @@ public sealed class AccountsStore
}
try
{
_vault.Store(account.Id, ownToken);
_vault.Store(account.TokenEntryId, ownToken);
}
catch(Exception compensationFailure)
{
@@ -339,12 +384,14 @@ public sealed class AccountsStore
{
return;
}
string name = _accounts.First(a => a.Id == id).Name;
Account removed = _accounts.First(a => a.Id == id);
string name = removed.Name;
Guid entry = removed.TokenEntryId;
// Vault first: a failing Delete leaves both stores untouched. Keep the
// token in memory only long enough to put it back if the file write fails.
string? removedToken = _vault.TryRetrieve(id);
_vault.Delete(id);
string? removedToken = _vault.TryRetrieve(entry);
_vault.Delete(entry);
try
{
@@ -359,7 +406,7 @@ public sealed class AccountsStore
try
{
_vault.Store(id, removedToken);
_vault.Store(entry, removedToken);
}
catch(Exception compensationFailure)
{
@@ -429,41 +476,87 @@ public sealed class AccountsStore
/// </summary>
private List<Account> LoadAtStart()
{
try
foreach(AccountsFile file in (AccountsFile[])[_gitHubFile, _forgesFile])
{
return ReadFile() ?? [];
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
_log.Error(
$"Could not read accounts from '{_filePath}' ({ex.Message}); no accounts are shown. "
+ "The file was left as it is.",
ex);
return [];
try
{
file.Refresh();
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
_log.Error(
$"Could not read accounts from '{file.FilePath}' ({ex.Message}); its accounts are not shown. "
+ "The file was left as it is.",
ex);
}
}
return Union();
}
/// <summary>
/// Brings what this store holds up to what the file holds now, before a change is
/// Brings what this store holds up to what the files hold now, before a change is
/// applied. Called with the file lock held. A file that is gone or damaged is not
/// taken for "no accounts": the change is then applied to what this store last
/// read, and writing it puts the accounts back.
/// read, and the accounts are put back here, before the change. So is an account
/// that is in the wrong file. When that cannot be written the change fails
/// before it touched the vault, and the change itself then has one file to write.
/// </summary>
private void RefreshForAChange()
{
List<Account>? current = ReadFile();
if(current is not null)
foreach(AccountsFile file in (AccountsFile[])[_gitHubFile, _forgesFile])
{
_accounts = current;
}
else if(_accounts.Count > 0)
{
_log.Error(
$"Accounts file '{_filePath}' is missing or damaged; the change is applied to the {_accounts.Count} "
+ "account(s) this session last read, which writes them back.");
int lastRead = file.Accounts.Count;
if(!file.Refresh() && lastRead > 0)
{
_log.Error(
$"Accounts file '{file.FilePath}' is missing or damaged; the change is applied to the {lastRead} "
+ "account(s) this session last read, which writes them back.");
}
}
_accounts = Union();
Persist(_accounts);
}
/// <summary>
/// The accounts of both files as one list. An account that is in both (by its
/// id) is the one of the file for other forges: that is the file gclo writes such
/// an account to, and the copy in accounts.json is what an older version may
/// have rewritten. A record of the file for other forges that names no forge
/// (gclo writes none) is not read as a GitHub account, which would give it
/// GitHub's listers and the default token: it names a forge nobody knows.
/// </summary>
private List<Account> Union()
{
List<Account> apart = [.. _forgesFile.Accounts.Select(a => a.ForgeName is null ? a with { ForgeName = "" } : a)];
HashSet<Guid> theirIds = [.. apart.Select(a => a.Id)];
return [.. _gitHubFile.Accounts.Where(a => !theirIds.Contains(a.Id)), .. apart];
}
/// <summary>
/// Whether an account is kept in the file for other forges: every account that
/// names a forge. An account that names none is a GitHub account as an older
/// version knows it.
/// </summary>
private static bool IsKeptApart(Account account) => account.ForgeName is not null;
/// <summary>
/// Writes each account to the file it belongs in. Only a file whose accounts
/// changed is written. The file for other forges goes first: an account that was
/// found in accounts.json is in its own file before it leaves the other, so a
/// failure in between leaves it in both, never in neither.
/// </summary>
private void Persist(List<Account> accounts)
{
_forgesFile.WriteIfChanged(accounts.Where(IsKeptApart).ToList());
_gitHubFile.WriteIfChanged(accounts.Where(a => !IsKeptApart(a)).ToList());
}
@@ -487,6 +580,11 @@ public sealed class AccountsStore
: account;
}
///////////////////////////////////////////////////////////////////////////////
// What a later version of gclo put on the account stays, whatever record the //
// caller built: one that was made anew (the wizard's) knows nothing of it. //
///////////////////////////////////////////////////////////////////////////////
account = account with { FieldsOfALaterVersion = onDisk.FieldsOfALaterVersion };
return onDisk.LastSyncUtc > (account.LastSyncUtc ?? DateTimeOffset.MinValue)
? account with { LastSyncUtc = onDisk.LastSyncUtc, LastSyncSummary = onDisk.LastSyncSummary }
: account;
@@ -495,50 +593,61 @@ public sealed class AccountsStore
/// <summary>
/// The accounts the file holds now, or null when it holds none that can be used:
/// the file is missing, or its content is not a list of accounts (it is then moved
/// aside). A file that cannot be read at all throws, and is not touched.
/// What a save writes for <paramref name="account"/> as far as its forge goes, or
/// a refusal. The forge and the server of an account say where its token is sent:
/// </summary>
private List<Account>? ReadFile()
/// <remarks>
/// <list type="bullet">
/// <item>They have to be usable (<see cref="Account.TryGetEndpoint"/>), and the
/// server is written the way it was normalized.</item>
/// <item>They are set when the account is added and cannot be changed by a save.
/// A token that was saved for one server is not sent to another because somebody
/// edited the address; the account is deleted and added again, with its token
/// entered for the new place.</item>
/// <item>The default token is a GitHub token: an account on another forge has a
/// token of its own.</item>
/// </list>
/// </remarks>
private Account WithItsForgeChecked(Account account)
{
string json;
try
if(!account.TryGetEndpoint(out ForgeEndpoint? endpoint, out string? reason))
{
json = WithPatience(() => File.ReadAllText(_filePath), FilePatience, accessDeniedIsBusy: false);
}
catch(Exception ex) when(ex is FileNotFoundException or DirectoryNotFoundException)
{
return null;
throw new ArgumentException($"Account '{account.Name}' cannot be saved: {reason}.", nameof(account));
}
try
Account? onDisk = _accounts.FirstOrDefault(a => a.Id == account.Id);
if(onDisk is null)
{
List<Account>? loaded = JsonSerializer.Deserialize(json, AccountsJsonContext.Default.ListAccount);
if(loaded is not null)
{
return loaded;
}
_log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts.");
account = account with { Server = endpoint.Server?.AbsoluteUri };
}
catch(Exception ex)
else if(onDisk.TryGetEndpoint(out ForgeEndpoint? was, out _) && was.Equals(endpoint))
{
///////////////////////////////////////////////////////////////////////////
// The text was read; it is what it says that cannot be used. That, and //
// only that, is a corrupt file. //
// The same place, however the caller spelled it. What the file says //
// about the place stays as it is, to the letter: the account's token is //
// found by it (AccountTokenEntry). //
///////////////////////////////////////////////////////////////////////////
_log.Error($"Accounts file '{_filePath}' is not a valid list of accounts; starting with no accounts.", ex);
account = account with { ForgeName = onDisk.ForgeName, Server = onDisk.Server };
}
else
{
throw new ArgumentException(
$"Account '{onDisk.Name}' is on {onDisk.DescribePlace()}, and that cannot be changed: its token was saved for that place. "
+ "Delete the account and add it again for the other one.",
nameof(account));
}
PreserveCorruptFile();
return null;
if(account.UsesDefaultToken && account.Forge != ForgeKind.GitHub)
{
throw new ArgumentException(
$"Account '{account.Name}' is on {endpoint.DisplayName}; the default token is a GitHub token, so the account needs a token of its own.",
nameof(account));
}
return account;
}
private TimeSpan FilePatience => _patience / FilePatienceDivisor;
/// <summary>
/// Takes the lock that every gclo process takes before it changes the accounts
/// file, waiting a short while for another process to finish. The lock is a file
@@ -548,7 +657,7 @@ public sealed class AccountsStore
/// <exception cref="AccountsFileBusyException">Another gclo process kept the lock for the whole wait.</exception>
private FileStream AcquireFileLock()
{
string? directory = Path.GetDirectoryName(_filePath);
string? directory = Path.GetDirectoryName(_lockPath);
if(!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
@@ -556,157 +665,16 @@ public sealed class AccountsStore
try
{
return WithPatience(
() => new FileStream(_filePath + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None),
return AccountsFile.WithPatience(
() => new FileStream(_lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None),
_patience,
accessDeniedIsBusy: false);
}
catch(IOException ex) when(IsBusy(ex, accessDeniedIsBusy: false))
catch(IOException ex) when(AccountsFile.IsBusy(ex, accessDeniedIsBusy: false))
{
throw new AccountsFileBusyException(
$"The accounts file '{_filePath}' is in use by another gclo process; nothing was changed. Try again in a moment.",
$"The accounts file '{_gitHubFile.FilePath}' is in use by another gclo process; nothing was changed. Try again in a moment.",
ex);
}
}
/// <summary>Runs <paramref name="action"/>, trying again for <paramref name="patience"/> as long as the file is busy.</summary>
private static T WithPatience<T>(Func<T> action, TimeSpan patience, bool accessDeniedIsBusy)
{
long started = Stopwatch.GetTimestamp();
while(true)
{
try
{
return action();
}
catch(Exception ex) when(IsBusy(ex, accessDeniedIsBusy) && Stopwatch.GetElapsedTime(started) < patience)
{
Thread.Sleep(s_pause);
}
}
}
/// <summary>
/// True for a failure that means "in use right now", which passes: on Windows a
/// sharing or lock violation, and, where the operation reports a busy target that
/// way (replacing a file somebody has open), a refused access. A missing file, a
/// full disk, or a path that is too long is never that, and is not waited for.
/// </summary>
private static bool IsBusy(Exception exception, bool accessDeniedIsBusy)
{
const int SharingViolation = 32;
const int LockViolation = 33;
if(exception is UnauthorizedAccessException)
{
return accessDeniedIsBusy;
}
return exception is IOException and not (FileNotFoundException or DirectoryNotFoundException or PathTooLongException)
&& (!OperatingSystem.IsWindows() || (exception.HResult & 0xFFFF) is SharingViolation or LockViolation);
}
/// <summary>
/// Moves an unreadable accounts file aside before the empty in-memory list can be
/// persisted over it: the metadata is what associates vault entries with accounts,
/// so clobbering a recoverable file would orphan every stored token.
/// </summary>
private void PreserveCorruptFile()
{
try
{
if(File.Exists(_filePath))
{
string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
File.Move(_filePath, aside, overwrite: true);
_log.Error($"Preserved unreadable accounts file as '{aside}'.");
}
}
catch(Exception ex)
{
_log.Error("Could not preserve the unreadable accounts file.", ex);
}
}
private void Persist(List<Account> accounts)
{
string? directory = Path.GetDirectoryName(_filePath);
if(!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Atomic replace: a crash or full disk mid-write must never truncate the live
// file — corrupted metadata orphans every vault token (accounts are found by
// Guid, and recreated accounts mint new ones).
string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount);
string tempPath = _filePath + ".tmp";
using(var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using(var writer = new StreamWriter(stream))
{
writer.Write(json);
writer.Flush();
stream.Flush(flushToDisk: true);
}
KeepABackup();
///////////////////////////////////////////////////////////////////////////////
// A file nobody may write to stays that way however long it is tried: say //
// so at once instead of taking the refusal for a file that is busy. //
///////////////////////////////////////////////////////////////////////////////
if(File.Exists(_filePath) && File.GetAttributes(_filePath).HasFlag(FileAttributes.ReadOnly))
{
throw new UnauthorizedAccessException($"The accounts file '{_filePath}' is read-only; nothing was changed.");
}
///////////////////////////////////////////////////////////////////////////////
// One step puts the new file in place of the old: the name is never absent, //
// so a process that reads without the lock always finds a whole file. //
// Something outside gclo (a scanner, a backup, a viewer) can hold the live //
// file for a moment; the step is then refused, and tried again for a while. //
///////////////////////////////////////////////////////////////////////////////
WithPatience(
() =>
{
File.Move(tempPath, _filePath, overwrite: true);
return true;
},
FilePatience,
accessDeniedIsBusy: true);
}
/// <summary>
/// Copies the file that is about to be replaced to accounts.json.bak. Best effort:
/// the backup is a convenience for a person, and failing to write it must not
/// stop the change it is a backup for.
/// </summary>
private void KeepABackup()
{
try
{
if(File.Exists(_filePath))
{
string backup = _filePath + ".bak";
if(File.Exists(backup))
{
File.SetAttributes(backup, FileAttributes.Normal);
}
File.Copy(_filePath, backup, overwrite: true);
}
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
_log.Error($"Could not write the backup '{_filePath}.bak' ({ex.Message}); the change goes ahead without it.", ex);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// The listers of an account whose forge or server cannot be used as they are
/// stored (an accounts file edited by hand, or written by a version that knows a
/// forge this one does not). It asks no server anything: every call fails with the
/// reason, so the account's token goes nowhere.
/// </summary>
internal sealed class UnusableAccountLister : IRepositoryLister, IOrganizationLister
{
private readonly string _message;
/// <summary>Creates the lister for <paramref name="account"/>, which cannot be used because of <paramref name="reason"/>.</summary>
public UnusableAccountLister(Account account, string reason)
=> _message = $"Account '{account.Name}' cannot be used as it is saved: {reason}. {account.AdviceWhenUnusable("Delete")}";
/// <inheritdoc/>
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
=> Task.FromException<IReadOnlyList<RepoDescriptor>>(new ForgeAccessException(ForgeAccessKind.NotFound, _message));
/// <inheritdoc/>
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
=> Task.FromException<IReadOnlyList<string>>(new ForgeAccessException(ForgeAccessKind.NotFound, _message));
}
+12 -3
View File
@@ -126,10 +126,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
AccountsStore? accountsStore = null,
IWslCloner? wsl = null)
{
_lister = lister ?? new GitHubRepositoryLister();
// Without listers of its own, a workspace gets those of its account's forge: Quick Sync is GitHub.
_lister = lister ?? (account is null ? new GitHubRepositoryLister() : AccountListers.RepositoryListerFor(account));
_git = git ?? new LibGit2GitClient();
_wsl = wsl ?? new WslCloner();
_orgLister = orgLister ?? new GitHubOrganizationLister();
_orgLister = orgLister ?? (account is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(account));
_progressFactory = progressFactory ?? DefaultProgress;
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
_log = log ?? new FileActivityLog(GcloPaths.LogDirectory);
@@ -587,8 +588,16 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// was opened. The record the workspace was opened with when the store no longer
/// has the account, or when there is no store.
/// </summary>
/// <remarks>
/// The place is always the one the workspace was opened for. Its listers were
/// made for that place and stay; a record that names another place by now (the
/// file was changed) must not bring the token of that other place to them.
/// </remarks>
private Account AccountAsItIsNow()
=> _accountsStore?.GetAll().FirstOrDefault(a => a.Id == _account!.Id) ?? _account!;
{
Account now = _accountsStore?.GetAll().FirstOrDefault(a => a.Id == _account!.Id) ?? _account!;
return now with { ForgeName = _account!.ForgeName, Server = _account.Server };
}
+7 -4
View File
@@ -562,10 +562,11 @@ public sealed partial class MainWindow : Window
_log.Error($"No account with id {id:N}; cannot open its workspace.");
return null;
}
// The listers are the account's own: its token goes to the forge and server it was added for.
return new WorkspaceViewModel(
UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()),
UiTestFixture.WrapRepositoryLister(AccountListers.RepositoryListerFor(account)),
UiTestFixture.WrapGitClient(new LibGit2GitClient()),
UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()),
UiTestFixture.WrapOrganizationLister(AccountListers.OrganizationListerFor(account)),
account: account,
tokenVault: _tokenVault,
accountsStore: _accountsStore,
@@ -778,9 +779,11 @@ public sealed partial class MainWindow : Window
{
// The vault is consulted only if step 2 needs the stored token for a
// validation call; the wizard never displays or retains it.
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id);
// Its token is checked against the place the account is on, never against another forge.
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.TokenEntryId);
IOrganizationLister organizations = existing is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(existing);
var viewModel = new AccountWizardViewModel(
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log,
_accountsStore, organizations, _settings, existing, storedToken, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))