153 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 e2e099ed08 feat(app): gclo opens one window per data folder, and a second start brings the open one to the front (#217)
windows / windows (push) Successful in 3m7s
build / windows (push) Successful in 3m7s
Two windows on one data folder each kept their own copy of the settings and
accounts, and the one that saved last undid what the other had saved. The
owner's decision (#216): one window per data folder.

- WindowClaim (gclo.ViewModels): a window holds window.lock in its data
  folder while it is open, shared with nobody. Windows gives it up when the
  process ends, however it ends. A folder that cannot be written to holds no
  claim and stops no window from opening.
- A second start finds the claim, asks the open window to come to the front
  through an event named after the folder, and ends before any window or
  file of its own exists. The open window comes out of the taskbar and
  before the other windows.
- The claim is given up first thing when the window closes, so a start right
  after closing opens a window of its own.
- Another data folder is another claim: the dev channel beside the stable
  one, and GCLO_DATA_DIR. The command line makes no claim.
- README, threat model (review log), CLAUDE.md.

Included, from the review of this pull request:
- fix(app): the claim on the data folder never stops a start, and is given up when no window came of it

Closes #216

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:55:16 +00:00
forge-recovery 9ab80f8c93 ci(gitea): build, test, coverage and format on the Windows host runner (#220)
windows / windows (push) Successful in 6m40s
build / windows (push) Successful in 6m40s
2026-11-11 22:42:24 +00:00
james.maesandClaude Fable 5.2 fa5887e6b3 docs(release): the documents name 1.2.0 as the release that moves updates to git.kof22.com (#219)
There is no release numbered 1.1.1: the fixes planned for it and Gitea
support go out together as 1.2.0 (owner's decision, #200). The README, the
CLI guide, ADR 0009, the header of the threat model and a comment in
Directory.Build.props still named 1.1.1.

docs/RELEASING.md is left to the work on the release pipeline (#197).

Closes #218

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:40:54 +00:00
james.maesandClaude Fable 5.2 d5d4f6763f feat(app): Settings holds a default token per forge, and a default token is used only for the place it was saved for (#215)
Step 5 of Gitea support (#201). Settings was all about GitHub; GitHub and
Gitea are equal options there too.

- Settings has a "Default tokens" section with two groups, named as
  everywhere else: GitHub (token) and Gitea (the server's address and its
  token). The Gitea address and token are saved, replaced and removed
  together; the saved token is never carried to another address, and a token
  for an address that cannot be used is not saved (Save is withheld, with the
  reason).
- The rule "the default token is a GitHub token" is replaced by: a default
  token belongs to one place and is never used for another. It is in the name
  of the vault entry: GitHub's is the one it always was, a Gitea server's is
  made from the server's address. Asking for the default token of a place
  finds the one saved for exactly that place, or none.
- Quick Sync fills in the default token of the place on its card.
- An account that uses the default token gets the one of its own place. The
  wizard offers the choice for a place that has one; accounts add and
  accounts edit take --use-default-token for any place that has one.
- A workspace is told which place's default token changed, and takes only its
  own.
- README, docs/CLI.md, ADR 0010, threat model T16.

Included, from the reviews of this pull request:
- fix(app): a default token is never left out of sight, and a wizard is given the default token of its own place only
- fix(app): a window that closes does not put older settings back, and a token typed behind the mask is not rewritten in its box
- fix(app): a default token that is gone when Save is pressed is not taken up, and the saved default is not given up for it

Closes #212

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:28:24 +00:00
james.maesandClaude Fable 5.2 b7d54735c8 feat(app): the next version is 1.2.0, and gclo describes itself as for GitHub and Gitea alike (#214)
- VersionPrefix is 1.2.0: a build from the line that brings Gitea support says
  1.2.0-dev, not 1.1.1-dev.
- The Share invitation and its subject, the command line's help line, the
  README's tagline and the engine package's description say GitHub or Gitea
  where they said GitHub.

Closes #211

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:28:14 +00:00
james.maesandClaude Fable 5.2 9a09cee62f feat(app): Quick Sync connects to GitHub or Gitea, sends a token only when asked, and the app opens where it was left (#210)
Step 4 of Gitea support (#201). GitHub and Gitea are equal options on the
card the app opens on, with the same two labels as the wizard.

- Quick Sync's card asks where the repositories are: GitHub or Gitea, and for
  Gitea the server's address. On a first run nothing is chosen, and the token
  box takes a token only once a place is. The title, the token box and the
  "How to create a token" link follow the place.
- Nothing is sent until the user asks. A token that is typed, pasted or filled
  in from a saved default goes nowhere by itself: the organization list is
  fetched when Find is pressed, and the repositories when Load is. Both go to
  the listers of the place on the card. An account's page lists and loads on
  open as before: its token was saved for its place.
- A server address is taken when its box is left, not while it is typed.
- Choosing another place empties the token, the organization and the table:
  the table's clone addresses are on the other host.
- The card opens the way the last connection that worked left it (place,
  address, organization, target folder, in settings.json). Never a token.
- Once a connection loaded its repositories, a bar offers to save it as an
  account; the account is saved on the place the connection was on.
- The app opens on the workspace that was open last; with nothing recorded,
  on the first account; with no account, on Quick Sync.
- The connection chip names the place, and the hint about a token that sees
  no organization is in the words of the forge.
- README, ADR 0010, threat model T16, CLAUDE.md.

Included, from the reviews of this pull request:
- fix(app): no place is chosen by keyboard focus, and the pane's button closes and opens the pane
- fix(app): Quick Sync uses no address that is being edited or cannot be used, and forgets what was typed before a place was chosen

Closes #209
Closes #213

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:25:02 +00:00
james.maesandClaude Fable 5.2 6764af9ff5 feat(app): the Add account wizard asks where the repositories are, and can add an account on a Gitea server (#207)
Step 3 of Gitea support (#201): the desktop app adds, loads and syncs an
account on a Gitea server.

- Step 1 of the wizard asks, for a new account, where the repositories are:
  GitHub (the default) or a Gitea server and its address. The address is
  checked by the rule the command line uses and shown as it was normalized,
  which is the address the token goes to.
- The wizard holds the lister of one place at a time. The shell gives it a
  lister per place, step 1 takes the one of the chosen place, and step 2 has
  no other to send the token to. A wizard that was given one lister (an edit,
  a Quick Sync connection saved as an account) offers no choice and says
  where the account is.
- What was entered for one place is not carried to another: when step 1 is
  left with another place than before, the typed token, the organization and
  the organization list are emptied. The default token is a GitHub token and
  is neither offered nor preselected for a Gitea server.
- Texts that named GitHub follow the place: the token box, the page a token
  is made on, the note of step 2, and the connect card of an account's page.
- The offline UI fixture answers for a Gitea server with an organization of
  its own, and the UI suite walks the wizard for one. The wizard's listers
  are wrapped by the fixture like a workspace's.
- README, ADR 0010, threat model T16.

Included, from the reviews of this pull request:
- fix(app): the wizard names the whole server address where the token is asked for, takes a place only with its lister, and does not move while a token is checked
- fix(app): the wizard's choice of place reads GitHub and Gitea

Closes #206

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:24:39 +00:00
james.maesandClaude Fable 5.2 390ca13b57 feat(accounts): an account names its forge and server, and the command line takes --forge and --server (#205)
Step 2 of Gitea support (#201): accounts and the command line can use a Gitea
server. The desktop app syncs such an account; its wizard adds GitHub accounts
only, for now.

- Account.Forge and Account.Server, left out of accounts.json when they are
  the default. They are set when an account is added and cannot be changed by
  an edit: a token that was saved for one server is not sent to another
  because an address was edited.
- The token of an account that is not on GitHub is kept under another entry
  than the account's id. A gclo from before forges reads every account as a
  GitHub account; it finds no token there, instead of sending a Gitea token to
  GitHub.
- The default token is a GitHub token: it is never handed to an account on
  another forge, and such an account cannot be saved as using it.
- ForgeEndpoint (engine) and AccountListers (view models) are the one place
  that pairs a forge, or an account, with its listers. An account whose stored
  forge or server cannot be used asks no server anything.
- Command line: --forge github|gitea and --server on sync, repos, orgs and
  accounts add; accounts list shows where an account lives; accounts edit and
  sync --account take no other place. The default token variable is per forge
  (GITHUB_TOKEN, GITEA_TOKEN), and one is never used for the other. Exit codes
  are by the kind of failure, whatever the forge.
- The app builds a workspace's listers from its account, and checks an edited
  account's token against the place the account is on.
- A refused server address is not repeated in a message.
- docs/CLI.md, README, ADR 0010, threat model T16.

Included, from the reviews of this pull request:
- fix(accounts): accounts on another forge are kept where an older gclo does not rewrite them, and a token is found only at the place it was saved for
- fix(accounts): a change writes one file, a workspace's token is the one of the place it was opened for, and a name two accounts share is refused
- docs(cli): a name two accounts share is refused with exit code 2, and the ADR says what happens to a record without a forge

Closes #204

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:22:16 +00:00
james.maesandClaude Fable 5.2 cecaeb96cd feat(engine): a forge-neutral failure type and listers for Gitea servers (#203)
The engine can list the organizations and repositories of a Gitea server (or
one that speaks its API) beside GitHub. Library only: accounts, the CLI and
the app do not use it yet (#201).

- ForgeKind, and ForgeAccessException with ForgeAccessKind: what a failed
  request to any forge means to a caller. GitHubAccessException derives from
  it and keeps its own Kind, so code written against it still compiles and
  still catches.
- GiteaServerUrl: the address of a server is HTTPS, without credentials, and
  without anything after its path. There is no setting for http.
- GiteaRepositoryLister and GiteaOrganizationLister, on HttpClient and
  source-generated JSON, with no new package. The token goes to the server
  the lister was made for and nowhere else: redirects are not followed, the
  address of a next page is never taken from an answer, and clone addresses
  are built from the configured address instead of being read from the
  server. Certificates are validated by the platform; nothing switches that
  off. Answers, lists and requests are bounded.
- Paging follows what the server says about a next page: a Gitea server caps
  the page size and leaves out what the token may not see, so a short or
  empty page does not end a list.
- A lister contract test suite that the listers of both forges pass.
- ADR 0010; threat model T16.

Included, from the reviews of this pull request:
- fix(engine): review of #203: a refused address is not repeated, a list ends only on what the server says about paging, and more is bounded

Closes #202

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 22:21:50 +00:00
james.maesandClaude Fable 5.2 e9f8377b13 fix(engine): a folder's own configuration names no proxy for the fetch, and git's own folder name is not a repository to clone into (#198)
Two small hardening items from the security review of 1.1.1. Neither exposed
a token; both make a rule that is already stated hold everywhere.

- A folder's own git configuration must not decide where a sync's fetch goes
  (#139). That was enforced for URL rewrites and the SSH program. A proxy
  named there (http.proxy, http.<url>.proxy, remote.<name>.proxy) and the
  folder's own http.followRedirects are refused now as well, before anything
  is fetched, with a message that names the setting. The user's global
  configuration is theirs and still applies.
- A repository named as git's own folder, in any spelling Windows opens as
  that folder, fails its row in the sync on every platform (until now only on
  Windows), and the WSL clone refuses it.

Threat model: T3 and T4 say so.

Also: one test added with #185 collected property changes in a plain list while
another thread could raise them; it failed once in a full run and is repaired
here (Refs #182).

Closes #184

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 06:26:57 +00:00
james.maesandClaude Fable 5.2 1915ce2f94 docs(release): the documents say what 1.1.1 does, and the package manifests verify the file they name (#199)
Statements the release reviews of 1.1.1 found untrue at this commit, and two
gaps in the release tooling.

- Threat model: the version reviewed; an installed build contacts
  git.kof22.com for updates; the binaries are signed (the accepted risks named
  them unsigned) and T1 with the Scoop part of T2 is what is accepted; T2 and
  T11 say what ADR 0009 changed, and T11 is not marked mitigated as a whole
  while the copy to the release server is manual.
- SECURITY.md names 1.1.x as the supported line.
- README: the beta is a separate app with its own accounts; parallelism is
  under Sync options, after loading; the buttons have the names on screen; the
  app is built unpackaged, with no Developer Mode. CONTRIBUTING the same.
  docs/CLI.md shows a current version.
- RELEASING.md: the check that the server answers from outside comes before
  anything is tagged; a version that moves the updater is rehearsed with two
  prereleases read from that server; the copy to git.kof22.com is a numbered
  step; the packaging change is committed on Gitea; winget is updated by hand
  while the job would submit the GitHub URL; the engine package is numbered by
  its own public surface.
- Update-PackageManifests.ps1 downloads the file the manifests will name and
  compares its SHA-256 with that of the built file it is given, instead of
  asking only whether the URL answers; it writes the Scoop manifest with the
  repository's line endings.
- One vault test stored an entry in the credential namespace the installed
  app uses. It uses two test scopes now (#161).

Closes #183

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 06:20:47 +00:00
james.maesandClaude Fable 5.2 165fda18f3 fix(app): an account's page takes no token it would ignore, and Sync all does not call an unsynced account synced (#185)
Findings of the release reviews of 1.1.1, each reproduced against the view
models before it was fixed, and checked again by the reviewers afterwards.

- An account's token is read from where it is saved each time it is used
  (#158), so a token typed into the box on an account's page looked taken and
  was not used. The box of an account takes no input now; the page says where
  the token is set, offers Edit account, and says what to do when the account
  has no token. Quick Sync keeps its box.
- Sync all: an account that could not be synced (no token, repositories that
  could not be loaded, a sync that could not start) needs attention and the
  pass ends as finished with failures, instead of a green "0 accounts synced,
  1 skipped". An account whose own sync was canceled is not counted as synced.
  The result says how many of how many accounts were synced and names each
  other group once; a canceled pass says how many were not started.
- Retry failed runs the failed rows without touching the selection. It used
  to select them, and a later Sync or Sync all then synced only those rows and
  reported the account as synced.
- "1 repository", not "1 repositories".

Closes #182

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 06:20:30 +00:00
james.maesandClaude Fable 5.2 6696e37049 feat(update): updates and release downloads come from git.kof22.com (#171)
The in-app updater reads the project's releases on its own Gitea server
through Velopack's GiteaSource, anonymously over HTTPS. The Scoop manifest
(homepage, download, version check, auto-update), the Chocolatey package and
the script that regenerates both for a release name the same server.

- ADR 0009 amends ADR 0001. The threat model records where that trust is now
  placed: the updater checks hashes from the same feed and no signature, so
  whoever can publish on the server can deliver an update. T1 is accepted for
  1.1.1, not mitigated.
- Update-PackageManifests.ps1 refuses to write a manifest whose download does
  not answer, so a package cannot be pushed before its release is on the
  server.
- RELEASING.md: what a release on git.kof22.com must contain, the prerelease
  flag, the ten-release window, and where a broken release is withdrawn.
- PackagingManifestTests pin the auto-update URL, the hash URL, the release
  notes link and the version check exactly.

Versions up to 1.1.0 look for updates on GitHub and are updated once by hand.

Closes #170

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 05:27:59 +00:00
james.maesandClaude Fable 5.2 388b57ea94 fix(clone): clones that were empty, cut off, or still ending are handled safely by the next sync (#175)
- A forced checkout into a folder whose tree is not finished (the first
  checkout of a clone that was empty, the completion of one that was cut off)
  refuses, before anything changes, when something that is not gclo's is where
  the repository has a file, needs a folder, or has a submodule; when a link
  is on the way; and when the index names a file the commit does not have.
- The folder is marked unfinished before its branch is created. An unfinished
  checkout is a marked folder without an index, or one that does not hold
  every file of its commit: it is completed at the commit its branch is on,
  and what an earlier attempt wrote is recognised by its content (or the
  beginning of it, line endings aside). A marked folder that has an index and
  every file is updated like any clone, without force.
- Where the branch of an unfinished checkout must move first (a tree Windows
  cannot write, a history origin rewrote), the commit it leaves is recorded
  and its files are recognised as well, also by the path recovery.
- An empty clone follows origin's default branch only when its folder holds
  nothing but .git and it has no branches of its own; a branch somebody
  started there is left alone.
- One git operation per folder at a time in a process. A canceled sync stops
  waiting for its operations without stopping them; a second operation in the
  same folder is refused until the first has ended.
- A clone without .git/index says so and how to rebuild it, instead of git's
  count of conflicts.
- Clone in WSL clones under ~/gclo/.gclo-tmp and renames the folder into place
  when it is complete; leftovers of killed runs are removed once a day old.
- README, docs/CLI.md and the threat model describe the behaviour and name
  what is not covered.

Four independent reviews; every finding fixed or named as a limit in the
threat model.

Closes #174

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 05:23:30 +00:00
james.maesandClaude Fable 5.2 48b3691828 docs(agents): CLAUDE.md covers the build traps, the test layout, and the architecture as it is now (#177)
Single-test commands, the CRLF and lock-file traps, how the three test projects divide the code, the architecture that takes several files to see, and the pieces added for 1.1.1. Changes go through an issue on the project's tracker at git.kof22.com.

Closes #176

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 02:19:25 +00:00
james.maesandClaude Fable 5.2 80eeb81edd fix(wizard): an empty token box asks for a token instead of showing a raw error (#173)
Step 2 of Add account with nothing typed says "Enter a token to continue." and asks nothing of the server, instead of showing the engine's argument error and logging a rejection for a token that was never sent.

Closes #172

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 02:03:08 +00:00
james.maesandClaude Fable 5.2 660dacb327 fix(app): the window stays responsive during a sync, Sync all says where it stands, and the table follows the sync (#169)
Progress reaches the window in batches (the latest report per repository, a few times a second), so a sync of any size leaves the window responsive: 281 clones with the account on screen went from 23 stalls (11.6 s) to none with the rows at rest, and to 11 short ones (longest 0.33 s) with the rows moving in the new order.

A status row above the workspace says for the whole Sync all pass which account is syncing, which of how many, and how far along it is, and holds the result afterwards; it replaces the dialog at the end. The activity log says when the window did not respond, for how long, and what was running.

The table's default order follows the sync: in progress, failed, finished (the latest first), waiting; when the sync is over, failed first, then by name. A column the user chose still wins, and a third click on it returns to the default. The row of chips above the table is gone.

Closes #167
Closes #168

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 01:51:38 +00:00
james.maesandClaude Fable 5.2 2deaf1c42a chore(links): point the project's links at its Gitea home (#166)
The Help menu, the About dialog, the splash text, the CLI's error text, the documentation, the issue templates and the package metadata name git.kof22.com as the project's home. The update feed and the release downloads move in a following change.

Closes #165

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 01:35:59 +00:00
james.maesandClaude Fable 5.2 0bdc43bd61 feat(app): Help > Share gclo by email, a ready-to-send invitation with download links (#164)
Help > Share gclo by email hands an invitation (what gclo is, where to download the latest release, how to install it) to the user's email program as a new message with no recipient. gclo sends nothing itself. When no email program takes it, or the invitation is too long for a link, the text is shown and copied to the clipboard.

Closes #163

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-11 01:32:28 +00:00
james.maesandClaude Fable 5.2 c85aa23ce4 test: the suites write nothing outside their own folders and send nothing anywhere (#162)
- Settings tests name a folder (AppSettings.Load and Save take an optional
  directory) instead of setting GCLO_DATA_DIR for the whole process, where
  a save could land in the developer's real settings.json.
- The Credential Manager vault tests use entry names of their own, never
  the names the installed app uses.
- The UI smoke test types a text too short to be taken for a token; the
  made-up token it typed before was sent to the real API to be refused.
- The synchronous progress double reports one at a time, as the UI thread
  does; several workers could enter the view model's handler at once.
- Every temp folder a workspace test asks for is removed afterwards.
- An assertion for the test that had none; time limits for two awaits.

A full run of the engine suite now leaves no folder behind under the temp
folder (it left three).

Closes #161

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 23:02:42 +00:00
james.maesandClaude Fable 5.2 f0b1e79163 fix(app): a saved token is read when it is used, not once when a workspace opens (#158)
An account workspace took its token from the vault in its constructor
and kept it. After the default token was replaced or removed in Settings,
open workspaces went on using the old one until the app was restarted;
after a default token was saved for the first time, Sync all still
skipped the accounts that use it; Quick Sync, which Settings pre-fills,
kept the old value too.

- A workspace reads its account's token from its source again when it is
  shown, before it loads, before it syncs (and before a WSL clone), and
  when Sync all reaches it. The source is taken from the account as the
  store has it now. A token that is gone by then stops the operation
  before any row is touched, with a message that names the source.
- The shell tells the open workspaces when Settings changed the default
  token. Quick Sync follows only while its box still holds what Settings
  put there; a token somebody typed stays.
- Sync all skips an account whose token is gone, whichever source it
  comes from, and reports a sync that could not start as failed.
- Reading the token again says nothing by itself: the organization lookup
  it starts does not write the status line or close an error, and the
  organization survives its token going away. A credential store that
  does not answer keeps the token read earlier and is logged.
- The account wizard shows the choice between the default token and a
  token of the account's own also for an account that uses the default
  token while none is saved, which was a dead end at step 2.
- The "no token" message names the credential entry the way the vault in
  use names it (entry names are scoped to the data folder).

Closes #157

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 22:59:24 +00:00
james.maesandClaude Fable 5.2 e80bbada37 fix(recovery): never discard local work, and deliver the commits it reports (#148)
Path recovery writes a working tree by hand, with force. It did that to
any repository whose update brought a path Windows cannot create, so
untracked files, uncommitted edits and local commits in an ordinary clone
could be replaced, and a branch could be moved off a commit made in the
folder.

Who may be rewritten

- A clone that git checked out normally is never rewritten, whatever its
  state: Resolve (and --sanitize-paths) refuses and says how to get a
  fresh clone with the renames. That includes a branch with no commits yet
  in a checked-out clone ('checkout --orphan'), and a repository cloned by
  git without a checkout.
- A repository that was never checked out, or is empty, can be resolved,
  but a file already where the repository has one is replaced only when it
  is, byte for byte, what the current commit has there.
- A repository path recovery wrote stays its to rewrite. It is recognised
  by gclo's own record (the recovery file, a marker in the repository's
  configuration), or, for repositories the released build wrote, by its
  shape (gclo's checkout marker, commits, no index, and a path that cannot
  be created here); the latter keeps working after its recovery file is
  lost.

Commits

- A branch moves only to the tip of the branch it tracks: forward, or,
  after origin rewrote its history, off a commit that this clone's record
  of earlier clones, fetches and pulls shows was origin's own. A commit
  made in the folder is never dropped; a diverged branch is refused.
- A pull that has nothing from origin writes nothing, so commits made on
  top leave every other file alone. A branch left ahead of the written
  tree by an interrupted pull is written.

Half-way states

- What can be found before anything changes is: another git process at
  work, a link on the way to a destination or to a file that is to be
  removed, a file where a folder is needed, a folder with someone's files
  where a file is needed. Failing there leaves branch, index and files as
  they were.
- Before the first destructive step the recovery file records that an
  apply is under way and everything that was and will be on disk, so the
  next pull completes an apply that was interrupted and still removes what
  left the tree.
- The index describes the written commit, attribute files are written
  first, a newer choice for a path replaces a stored one, and a file that
  left the tree where a folder of the user's now sits leaves that folder.

The CLI keeps the list of invalid paths on a repository whose
sanitization failed. Docs and the threat model describe the above.

Closes #147

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 22:55:32 +00:00
james.maesandClaude Fable 5.2 d518504e47 fix(cli): exit codes, option handling and Ctrl+C for accounts and dry runs; the log follows GCLO_DATA_DIR (#160)
- `gclo sync --dry-run` canceled while the repositories were being
  listed exited 0 with an empty plan. It now says it was canceled, prints
  no plan (and no document), and exits 1.
- A failure of the accounts file or of Windows Credential Manager ended
  `gclo accounts` and `gclo sync --account` with exit code 70 ("please
  report it"). It is now a fatal error with its reason (2), including what
  Windows says the error number means, or "retry later" (4) when another
  gclo process was changing the accounts. An edit of an account that
  another process removed meanwhile is a fatal error too.
- `gclo accounts -h` was an unknown subcommand. `--description=` was
  rejected although `--description ""` works.
- `gclo accounts edit` accepted a blank --rename, --org, or --target.
- A relative --target was stored as typed, so an account meant a
  different folder depending on where it was used from. It is stored as a
  full path, checked before the token is read; a target that cannot be a
  folder (characters no folder name can have, a device name, an unexpanded
  '~' or '%NAME%') is refused.
- The CLI's activity log ignored GCLO_DATA_DIR while accounts and settings
  followed it. The log folder is now "logs" under the data folder for the
  app and the CLI alike (GcloPaths.LogDirectory).
- Ctrl+C: the first one was held back ("Canceling...") also while waiting
  at the token prompt, where nothing then happened. It now ends the
  process there. A change to the accounts is let through to its end, so it
  is never cut off between the credential store and the accounts file
  (CancelKeyPolicy).

Closes #159

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 22:53:05 +00:00
james.maesandClaude Fable 5.2 8c1587c0e4 fix(accounts): accounts.json is safe across gclo processes; an unreadable file is not a corrupt one (#156)
The store read accounts.json once and wrote that snapshot back on every
change. With the desktop app open, an account added from the CLI was
erased by the app's next write (recording a sync result was enough), and
its token stayed in the vault with nothing pointing at it. A long
`gclo sync --account` did the same to the app, and two app instances to
each other.

- Every change takes a lock that every gclo process takes
  (accounts.json.lock, opened for one process alone), reads the file
  again, applies its one change by account id to what the file holds at
  that moment, and writes that. An account another process added or
  removed stays added or removed; a sync result it recorded is kept; an
  edit of an account that was removed meanwhile is refused instead of
  bringing it back without its token.
- The new file is put in place in one step, so the name is never absent
  and a process that starts while another saves finds the accounts.
  Start-up reads without the lock: a stalled process cannot keep another
  from starting.
- A file that cannot be read is not taken for a corrupt one. Only content
  that is not a valid list of accounts is moved aside. A file in use is
  tried again briefly; one that stays unreadable is left where it is. A
  change that finds the file gone or damaged is applied to what the store
  last read, which writes the accounts back.
- Waiting is for what passes: a sharing violation, by its code. A
  read-only file fails at once. A lock held for the whole wait is an
  AccountsFileBusyException; the CLI exits 4 (retry later) for it.

settings.json is written beside the file under a name of its own and
moved into place, with a short retry while something has the file open; a
failed load or save is logged, Save says whether it succeeded, and
nothing is left behind. The "0 means absent" special case for the splash
duration described something that does not happen.

Tests: the three classes that set GCLO_DATA_DIR for the process share one
collection, so they never run beside each other.

docs/CLI.md named the wrong folder for accounts.json; corrected. The
threat model names the lock file.

Closes #155

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 22:06:07 +00:00
james.maesandClaude Fable 5.2 46c6ca892a fix(app): scope the UI test fixture to its token, never in an installed build (#146)
With GCLO_UITEST_FIXTURE=1 the fixture's git client answered "not a
repository" for every folder, whatever the token, so a real session
started with the variable set treated every existing repository as
missing. The variable was also honoured by installed release builds,
and nothing recorded that it was in effect.

- The fixture asks the real git client whether a folder is a repository;
  its own clones write nothing, so the fixture token still always clones.
- An installed build (one with a Velopack app id) ignores the variable.
- The app logs one line when the fixture is enabled; a UI test asserts it.

Threat model: the interface row for the variable says what now holds.

Closes #145

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 21:46:47 +00:00
james.maesandClaude Fable 5.2 cc3aeecdf4 fix(github): classify access, network and server failures; cancel a listing promptly (#154)
The listers translated a rejected token, rate limits and "not found".
Everything else escaped as a raw exception: a refusal of access (403, an
organization that enforces SAML single sign-on, for example), a rate limit
answered with 429, an error on GitHub's side (5xx), a network failure, and
a request that timed out. The CLI reported those as exit code 70, a bug in
gclo, and the desktop app took a timed-out organization lookup for a
superseded one and showed nothing.

- GitHubFailures is the one mapping both listers use. 403 (and 451) is
  Forbidden, with GitHub's own reason when its API gave one; 429 is a rate
  limit; 5xx, a network failure, a proxy that wants a sign-in, and a
  timeout are the new GitHubAccessKind.Unavailable. A failure it does not
  know stays what it is.
- A message never copies text it did not get from GitHub's API: a network
  failure is described by its kind (the platform's text can name a proxy
  with its password), and a page from a web filter is not a reason.
- The CLI maps Unavailable to exit code 4 (retry later). For these cases
  the exit code changes from 70 to 3 or 4; the help texts and docs say so.
- Each gateway request is awaited with the caller's token, so Ctrl+C
  during a request that hangs no longer waits for the HTTP timeout. A
  cancellation the caller did not ask for is a timeout; that is decided
  once. An abandoned request that fails later is looked at, so it never
  surfaces as an unobserved failure.
- OrgSyncEngine reports through RunProgress, which drops whatever arrives
  for a repository after its final state in the run. A git operation
  abandoned on cancel keeps reporting transfer progress; a row that said
  Canceled could go back to "Cloning 40%", and the CLI could print a
  progress line after the summary.

README.md listed a rejected token under exit code 2; its table now has
3, 4 and 70 as docs/CLI.md does.

Closes #153

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 21:39:18 +00:00
james.maesandClaude Fable 5.2 7bb44cd132 fix(engine): small correctness fixes found in the 1.1.1 review (#152)
- A target folder that is a drive root ('C:\', '/') rejected every
  repository: the root keeps its separator when trimmed, and a second one
  was appended. The root now ends in exactly one.
- The Windows-invalid character set was asked of the running platform, so
  the Windows rules answered differently elsewhere. It is written out.
- SyncSummary.WasCanceled reported the token at the end of the run, so a
  cancellation that arrived after the last repository finished marked a
  complete run as canceled (and the CLI exited 1). From the engine it is
  now true exactly when a repository was canceled; the CLI sets it when
  cancellation kept a --sanitize-paths recovery from happening.
- A concurrency read from accounts.json was used as stored. Account now
  keeps it between 1 and 64 however it is set; a value below 1 (also what
  a file without the field is read as) becomes the default. A missing
  description is read as empty, not null.
- ProcessRunner wrote the child's input outside the block that ends the
  child, and a child that exited before reading its input failed the call
  with an I/O error. Every way out now ends a child that is still alive,
  and such a child answers with its exit code and error output.
- The gateway cache was keyed by the token and emptied itself when full.
  GatewayCache keeps eight clients, keyed by a hash of the token, and
  drops only the one unused the longest.
- The activity log: timestamps carry their offset from UTC; the clock is
  read once per entry, so the timestamp and the daily file agree at
  midnight; daily files older than 30 days are removed; a write that finds
  the file held by another process (the app and the CLI can share it)
  waits briefly and tries again instead of dropping the entry, and after
  giving up once no write waits again for 30 seconds.
- The invalid-paths message ended with "Nothing was checked out.", which
  is wrong for a repository that already has a working tree. It now ends
  with "Nothing was written to the working tree."

docs/CLI.md named the wrong log folder; corrected with the above.

Closes #151

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 21:32:35 +00:00
james.maesandClaude Fable 5.2 c4b1f5379a fix(recovery): plans are checked by where paths end up; suggestions are valid (#150)
Two defects in how rename choices for Windows-invalid paths were prepared
before they reached the engine:

- The Resolve dialog's pre-flight validated each replacement as typed. A
  renamed entry inside a renamed folder is written with the folder's
  original name in front, so the check saw the invalid name and refused a
  plan the engine accepts; only skipping the file got past it.
- --sanitize-paths trusted the suggested names. Two paths can be given the
  same suggestion, and some suggestions were themselves invalid: "x?."
  became "x?", and "COM3.txt" became "COM3.txt_", which is as reserved as
  the name it replaces. The repository then failed to sanitize.

Now:

- PathRecovery.EffectivePathOf says where a path ends up once the renames
  of it and of the folders above it are applied (nowhere, when it or a
  folder above it is skipped). The pre-flight validates those places, and
  reports a replacement that names another folder, since only the name is
  applied.
- Whether a renamed path is a folder is decided by the original paths (it
  holds another renamed path), so a file renamed onto a name that another
  rename uses as a folder is a clash, and two folders given one name are a
  clash too, reported with the rows they come from. The work per path does
  not grow with the number of paths.
- Every suggested name is put through the rules until it passes; a
  reserved device name is fixed in its base name ("COM3_.txt"); an
  overlong name is cut between characters, never through one.
- The automatic plan moved next to the planner
  (PathRecoveryPlanner.FromSuggestions) and skips what its renames cannot
  place: two folders given the same name are both skipped, never merged.
  Its result is checked against the real engine in tests.
- --sanitize-paths prints where each renamed path lands and counts only
  the renames that are applied.

A suggested name that collides with a path the repository already has is
still only caught by the engine, which fails that repository cleanly; the
documentation says so.

Closes #149

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 20:53:39 +00:00
james.maesandClaude Fable 5.2 9ccff603c8 fix(log): show control characters from repository content as visible text (#144)
Text that comes from a repository or a server reaches the activity log
and, in the CLI's text modes, the terminal. A file path in a git tree may
contain a newline, an escape sequence, or a character that reorders or
hides the text around it, and such a path is exactly what gclo reports
when it cannot be created on Windows. A newline started what read as a new
log entry, and an escape sequence was obeyed by the terminal.

- New DisplayText.Escape decides per code point, by Unicode category:
  control characters, line and paragraph separators, format characters
  (direction marks and overrides, invisible characters, tag characters)
  and unpaired surrogates become a visible backslash-u form; the
  zero-width joiner and non-joiner stay. Ordinary text comes back as the
  same string.
- FileActivityLog writes the message on one line, and an exception's
  lines indented under their entry, so no line of it can start like an
  entry (the Log window's error filter groups lines by that prefix).
- The CLI writes every line through one place, Terminal. Besides the
  above it shows any character the console's code page cannot represent
  in the same visible form, because a legacy code page turns some
  printable characters into control bytes (on code page 437 a left arrow
  becomes ESC). Fixed help text and JSON documents are written as they
  are; the JSON encoder already escapes.
- Tests: every character of the basic plane, encoded for code pages 437,
  850, 1252 and 932, yields no control byte; no other CLI file writes to
  the console.

Threat model: T15 added, saying what is covered (log and CLI) and what is
not (the desktop app's own surfaces, look-alike text). docs/CLI.md
describes the log format.

Closes #143

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 20:00:59 +00:00
james.maesandClaude Fable 5.2 539b0867e1 fix(recovery): keep every write and delete inside the working tree (#142)
Path recovery materializes the working tree itself instead of using a git
checkout, and it was less careful than one: the list of previously written
files in .git\gclo-recovery.json decided what to delete without being
checked, a path segment named .git was valid, writes followed links, and
cleanup of a failed clone followed directory links.

- New internal WorkingTree is the only way the recovery writes or deletes
  a file. The target must resolve inside the working directory; no part of
  it may be a name Windows opens differently from how it is written (empty,
  trailing dot or space, a colon) or git's own folder (.git or GIT~1, with
  trailing dots, spaces or a stream suffix); and it must not be reached
  through a link, recognized by having a target so that cloud-synced
  folders are not mistaken for links. Whatever sits at the destination (a
  file, a hard link, a file or folder link) is removed and the file created
  anew, never written through.
- The recovery file's stored paths are validated when it is loaded. A file
  that lists a path outside the working tree fails the row with a message
  that says how to rebuild it; nothing is deleted.
- WindowsPathValidator treats .git and GIT~1 as an invalid segment, with a
  suggested replacement that itself validates, so a tree entry or a rename
  can no longer land in git's own folder.
- Cleanup of a failed clone goes through WorkingTree.DeleteDirectory, which
  removes a link inside the folder as a link and never enters it.

Threat model: T3 and T4 updated, with the check-then-write residual named.

Closes #141

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 19:33:11 +00:00
james.maesandClaude Fable 5.2 dc8f0d3b5c fix(sync): pull only a clone of the listed repository, over the listed URL (#140)
A folder with a repository's name used to be pulled through whatever
origin it had. A same-named clone of a fork, of another owner's
repository or of another host was reported Updated although the listed
repository was never synced, and the remote and transport were whatever
that folder's configuration said.

- IGitClient.FetchAndPullAsync takes the clone URL the repository was
  listed with. A folder whose origin names a different repository fails
  its row before any request; the message names both without any
  credentials stored in the origin, and gives the git remote set-url
  command for a repository that was renamed or moved.
- The remote contacted is the listed URL. An origin that names the same
  repository in another spelling (SSH, plain HTTP, embedded credentials)
  is fetched through a remote that exists only for that call, marked as
  gclo's; origin is never rewritten, a remote of that name the user made
  is never touched, and tags and pruning behave as before.
- A url.<base>.insteadOf entry in the folder's own configuration is
  refused, and so is its core.sshCommand when the fetch is not HTTPS. The
  user's global git configuration still applies.
- The git credential callback answers only a remote whose URL is HTTPS on
  the listed URL's host and port. The default-branch retarget gets a
  provider of its own instead of sharing the fetch's replay counter.
- WslCloner validates the organization and repository as single names,
  requires an HTTPS URL passed after "--", scopes the credential helper
  to the URL's host, and pulls an existing destination only when its
  stored origin is the listed URL. The view model passes the organization
  the table was loaded for.
- The repository lister refuses a name that is not one plain name before
  making a request.

New internal RemoteUrl holds the decisions (same repository, may receive
credentials, safe-to-show form) and is covered by table tests. Threat
model: T14 added, T4, T8 and T9 updated.

Closes #139

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 18:55:23 +00:00
james.maesandClaude Fable 5.2 2526eea747 build: pin the single-file analyzer package so lock files survive SDK patches (#138)
The SDK adds Microsoft.NET.ILLink.Tasks implicitly, at its own runtime
version, for projects with EnableSingleFileAnalyzer. global.json rolls
forward to the newest 10.0 SDK, so each SDK patch resolved a different
version: a locked restore failed with NU1004 and an unlocked one rewrote
three lock files.

Directory.Build.targets now fixes the version through the SDK's
KnownILLinkPack item, at the one bundled with the SDK named in
global.json. The committed lock files are unchanged and restore in locked
mode with SDK 10.0.403, for a build and for the single-file publish.

Also:
- nuget-updates: commit Directory.Packages.props with the weekly bump;
  without it the pull request carried lock files that no longer matched.
- VersionPrefix: 1.1.1, the next release after v1.1.0.

Closes #137

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-10 18:15:50 +00:00
james.maesandClaude Opus 5.5 f0f9f6ccb1 docs: say Chocolatey and winget are not available yet (#134)
## What
The README told users to run `choco install gclo` and listed Chocolatey
and winget as official channels. As of 2026-10-13 neither is live:
- Chocolatey: the only submitted version, 1.0.1, is in community
moderation with the status **"Waiting for Maintainer to take corrective
action"**. It passes the automated checks, so the hold is the human
reviewer's notes and the nuspec guideline items about the description
URL and the docs/source URLs. 1.1.0 (bucket and nuspec are at 1.1.0) has
not been pushed.
- winget: not submitted (#7).

This PR removes the `choco install` line and states plainly what is
available today: GitHub Releases, the in-app updater and Scoop.

## Owner follow-up (not in this PR)
1. Sign in to community.chocolatey.org as `koftwentytwo` and read the
reviewer comments on gclo 1.0.1.
2. Fix the nuspec URLs, then push 1.1.0. It supersedes 1.0.1 in
moderation.
3. Once approved, restore `choco install gclo` in the README.

Docs only: no code or packaging change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:27:19 -05:00
james.maesandClaude Fable 5.1 50bae32c93 build(chocolatey): address the moderation validator's guideline notes (#133)
chocolatey.org's validator passed gclo 1.0.1 but noted a possibly
invalid URL in the Description, identical projectUrl/projectSourceUrl,
and DocsUrl/LicenseUrl for reviewer verification. projectUrl is now the
README (product page) with projectSourceUrl the repository, licenseUrl
is the raw LICENSE, and the Description drops the inline URL and the
angle-bracket placeholder. The packaging script still rewrites only
version, URL and checksum; PackagingManifestTests pass.

Closes #131.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 07:59:08 -05:00
james.maesandClaude Fable 5.1 1709a0f2be ci(release): open the packaging manifests PR before the Chocolatey push (#130)
On v1.1.0 the Chocolatey push failed with HTTP 403 because
chocolatey.org still held 1.0.1 in moderation (one version per package
in the queue), and the manifests PR, which runs after the push, never
opened (#123 was made by hand). The `packages` job now opens the
manifests PR right after updating the manifests, then packs and pushes
to Chocolatey; a failed push still fails the job so it is re-run when
the queue clears. RELEASING.md documents the behaviour.

Closes #129.

Tested: actionlint clean (the steps are unchanged, only reordered; the
PR step's `add-paths` keeps the ignored `artifacts/` out of the commit).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 07:40:19 -05:00
james.maes 6049e05c4f build(packaging): point the Scoop and Chocolatey manifests at v1.1.0 (#123)
Generated by `Update-PackageManifests.ps1` for tag `v1.1.0` (same as the
release workflow's packaging PR). Opened by hand because the `packages`
job of run 38138151291 failed at the Chocolatey push (HTTP 403: the
`gclo` 1.0.1 package is still in chocolatey.org's moderation queue)
before reaching this step. `scoop update gclo` picks this up once
merged. The Chocolatey push is re-run when moderation clears.

Relates to #9.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
2026-10-11 07:31:14 -05:00
james.maesandClaude Fable 5.1 198a56213b ci(release): verify the CLI zip under the edition's app id (#117)
On v1.1.0-beta.1 every job passed and the release is fully signed
(verified locally: 289 files, 0 failures, attestation OK), but
`verify-signatures` looked for `gclo-cli-win-x64-…zip` while the dev
edition ships `gclo-dev-cli-win-x64-…zip`. The job now uses the app id
for the CLI name too. RELEASING.md corrected and notes that both
editions share Credential Manager entries.

Closes #116.

Tested: actionlint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
v1.1.0
2026-10-10 19:12:03 -05:00
james.maesandClaude Fable 5.1 d77049eb3c feat(release): pack the dev channel as its own app so it installs beside stable (#115)
Prereleases now pack as Velopack app id **gclo-dev** while stable stays
**gclo**, so a dev build installs beside the stable app: separate
install folder, Start menu entry, update feed and data root
(`%LOCALAPPDATA%\KofTwentyTwo\gclo-dev`), with `(dev)` in the window
title.

- `Program.cs` reads the installed app id from `VelopackLocator.Current`
and sets `GcloPaths.Edition`; `GcloPaths.DefaultDataRoot` follows it
(pure mapping unit-tested). Local and CLI runs stay on the stable paths.
- `release.yml`: `app-id` derived from the tag (prerelease →
`gclo-dev`); `verify-signatures` downloads the matching asset names
(`gclo-dev-dev-Setup.exe` …).
- `docs/RELEASING.md`: the split, the asset names, and that dev installs
made before the split (app id `gclo`, up to 1.0.2-beta.3) must be
uninstalled or replaced by the stable app.

Closes #114 (part of #62).

Tested: build 0 warnings, format clean, actionlint clean, Engine tests
480/480 with 100% coverage on Engine and ViewModels (the
real-Credential-Manager round-trip test flaked once in a parallel run
and passes on its own and on re-run).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
v1.1.0-beta.1
2026-10-10 18:47:26 -05:00
james.maesandClaude Fable 5.1 dda0a22287 feat(help): Submit an issue and Community support links (#113)
Help menu gains **Submit an issue…** (GitHub's new-issue chooser, which
offers the repository's bug and feature templates) and **Community
support…** (GitHub Discussions, enabled on the repository), opened in
the default browser like *View on GitHub*. The three items are separated
from *Check for updates…*; each has an automation id and a tooltip.

Closes #108.

Tested: build 0 warnings, format clean, FlaUI suite 11/11 (one
name-filter test flaked once and passes on its own).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:35:53 -05:00
james.maesandClaude Fable 5.1 f4014ff5c9 feat(settings): show a saved default token as a mask and say what Save will do (#112)
## What

Step 3 of the approved [UX plan](docs/plans/default-token-ux.md),
stacked on #106 (step 2) and #103 (step 1).

- `SettingsTokenState` (ViewModels, fully tested): the box's initial
value (a fixed mask when a token is saved, never the token), what Save
does for a given box value (keep / store / remove), whether the Remove
link applies, and the caption text, which names who depends on the token
and what Save will do.
- `SettingsDialog`: shows the mask when a token is saved; the caption
follows every edit; clearing the box or pressing *Remove saved token*
removes on Save; a removal that breaks accounts turns the caption
critical and names them, so Save is an informed confirmation (inline,
not a second dialog: a `ContentDialog` cannot open another and the
dialog guard allows one at a time; the plan is updated to say so).
Replacing the token says the dependent accounts rotate with it. The
dialog now takes the `AccountsStore` to count and name them.

Closes #101 (part of #62).

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 475/475 (+16 for `SettingsTokenState`); line
coverage 100% on `gclo.Engine` and `gclo.ViewModels`.
- FlaUI suite run locally against the rebuilt app (result in the PR
comments).

Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:29:55 -05:00
james.maesandClaude Fable 5.1 472201f0cf feat(wizard): offer the default token when adding or editing an account (#111)
## What

Step 2 of the approved [UX plan](docs/plans/default-token-ux.md),
stacked on #103 (step 1). Part of #102.

- **Wizard step 2** (AccountWizardViewModel, AccountWizardDialog): when
a default token is saved, a radio group offers *Use the default token
from Settings* (preselected for a new account) or *Use a different token
for this account*; the token box is enabled only for the latter. The
caption says who else uses the default token and, on an edit switching
to it, that the account's own token will be removed. Validation fetches
the default token for that one call only; it is never loaded into the
box. Missing default token → a Settings-specific message. Edit mode
starts from the account's current source and can switch either way. The
seeded wizard preselects the default when the Quick Sync token is the
default token (compared in memory).
- **Pane**: default-token accounts show a *Default token* tag next to
the name (and in the automation name), refreshed on edit.
- **Sync All**: a default-token account with no default token saved is
skipped with the reason "no default token is saved (Settings)" before
any GitHub call.
- MainWindow passes the default-token accessor to both wizard
constructors.

Closes #105.

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 459/459 (+12: wizard choice/validation/save in
both modes, seeded preselection, caption counts, Sync All skip and run);
line coverage 100% on `gclo.Engine` and `gclo.ViewModels`.
- FlaUI suite run locally against the rebuilt app (result in the PR
comments).

Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:24:44 -05:00
james.maesandClaude Fable 5.1 b9c0faa41c feat(accounts): reference the default token instead of copying it (#110)
## What

Step 1 of the approved [UX plan](docs/plans/default-token-ux.md) (#101,
#102): the model, the resolver, the store and the CLI. No desktop UI
change yet (step 2: the wizard; step 3: Settings).

- `Account.TokenSource` (`Own` | `Default`): `Default` accounts
**reference** the default token saved in Settings and never copy it, so
rotating it rotates every such account. `Own` is the on-disk default and
is omitted from `accounts.json`, so existing files and vault entries
stay byte-identical (round-trip tests).
- `AccountTokenResolver`: the one place that turns an account into its
token, with a source-specific "missing" description. Used by
`WorkspaceViewModel`, Sync All (through the workspace) and `gclo sync
--account`.
- `AccountsStore`: a `Default` account is saved with no vault entry (a
token for it is rejected); switching to `Default` removes the own entry
vault-first with the same compensation as a delete (consistency tests
for the restore and the double-failure paths);
`CountUsingDefaultToken()` for Settings.
- CLI: `accounts add|edit --use-default-token` (fatal when no default
token is saved; exclusive with the other token options), `accounts list`
token column + `tokenSource` JSON field, `sync --account` errors name
the source and the fix. Help text and `docs/CLI.md` updated.
- Threat model T5 + revision row.

Closes #104 (part of #102, #62).

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 447/447 (+22), `gclo.Cli.Tests` 142/142 (+13);
line coverage 100% on `gclo.Engine`, `gclo.ViewModels` and `gclo`.

Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:17:33 -05:00
james.maesandClaude Fable 5.1 935430c84a ci(release): run verify-signatures in the release environment (#100)
The job checks the publisher against TRUSTED_SIGNING_SUBJECT, a
elease-environment variable, but did not run in that environment and so
read an empty value (v1.0.2-beta.3, run 38087007507). Adds nvironment:
release. The release itself was fully signed: verified locally with
signtool on all 289 executables and libraries across Setup, the portable
zip and the CLI zip, 0 failures.

Closes #99 (part of #62).

Tested: actionlint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 18:10:40 -05:00
james.maesandClaude Fable 5.1 7234c18549 ci(release): re-pin the shared Velopack workflow to the signing fix (#98)
Re-pins elease-velopack.yml to standards 40ecebae (standards#29): signs
only unsigned binaries (48 of 287 instead of all), vpk signing through a
SignTool wrapper instead of its hanging built-in path, and signtool
verify /pa /all over Setup.exe and the portable zip before the draft
release. Reproduced locally end to end against the live signing account
before this change was made (see standards#29).

Closes #97 (part of #62).

Tested: actionlint clean. First CI run: v1.0.2-beta.3.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
v1.0.2-beta.3
2026-10-10 16:16:06 -05:00
james.maesandClaude Fable 5.1 2c4726e30e feat(release): Authenticode-sign releases with Azure Artifact Signing over OIDC (#96)
## What

Releases are now Authenticode-signed (K22-REL-05) through the standards'
shared workflow, which already carries the signing steps behind `sign`
(`azure/login` v3.1.0 and `azure/artifact-signing-action` v2.0.0, both
SHA-pinned and current; `vpk pack --azureTrustedSignFile`). ADR 0008.

- `release.yml`: `sign: true`; new `verify-signatures` job after
`release` downloads the published Setup.exe, portable zip and CLI zip
and runs `signtool verify /pa /all` + `Get-AuthenticodeSignature` on
every gclo executable, failing on an invalid, untimestamped or
foreign-publisher signature (`TRUSTED_SIGNING_SUBJECT`).
- Authentication is GitHub OIDC: an Entra application
`gclo-release-signing` with a federated credential for
`repo:KofTwentyTwo/gclo:environment:release` and only *Artifact Signing
Certificate Profile Signer* on the certificate profile. No secret, key
or PFX anywhere. The identity is `release`-environment **variables**:
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID`,
`TRUSTED_SIGNING_ENDPOINT`, `TRUSTED_SIGNING_ACCOUNT`,
`TRUSTED_SIGNING_PROFILE`, `TRUSTED_SIGNING_SUBJECT`.
- `.github/scripts/Set-ReleaseSigning.ps1`: idempotent Azure
configuration (reads the existing account and Active profile through
ARM, ensures app + service principal + federated credential +
profile-scoped role, prints the `gh variable set` lines). Run
interactively by the owner; never creates secrets or touches the
account, validation or profile.
- Docs: RELEASING.md ("Code signing", local signing with SignTool + the
`Microsoft.ArtifactSigning.Client` dlib, the verify job), SECURITY.md,
README verify step 4, threat-model revision row.

Closes #57 (part of #62). After the first signed release verifies,
EX-0002 closes for gclo in the standards register.

## Azure configuration status

Pending the owner's interactive sign-in; the script output and `signtool
verify` results of the first signed prerelease will be posted here
before merge.

## How it was tested

- `actionlint` clean; PSScriptAnalyzer 0 findings.
- Verification of a real signed build: pending the Azure configuration
(recorded here when done).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
v1.0.2-beta.2
2026-10-10 14:39:26 -05:00
james.maesandClaude Fable 5.1 c4d84779ab fix: keep per-user data out of the Velopack install root (#94)
## What

Running any `Setup.exe` over an existing install wiped `accounts.json`,
`accounts.json.bak`, `settings.json` and `logs\` on the maintainer's
machine (#91): Velopack uninstalls the previous version first and
deletes the whole install root, and gclo stored its data in that same
folder, `%LOCALAPPDATA%\gclo`.

- Default data root is now `%LOCALAPPDATA%\KofTwentyTwo\gclo`
(`GcloPaths.DefaultDataRoot`); the CLI's and the activity log's default
follow. `GCLO_DATA_DIR` still overrides it.
- **No migration**, as agreed: single user, single machine, green
fields. The default vault scope is still unscoped, so the existing
`gclo:account:<id>` Credential Manager entries keep working once
accounts are re-added.
- README, `docs/CLI.md`, the two store doc comments and the threat model
(asset table + revision row) say where the data lives and why.
- Tests: default root is the new path and is never under the install
root; same for the log directory.

Closes #91 (blocks #67). Part of #62.

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 425/425, `gclo.Cli.Tests` 129/129; line coverage
100% on `gclo.Engine` and `gclo.ViewModels`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:30:48 -05:00
james.maesandClaude Fable 5.1 e411c30344 ci: retry PSScriptAnalyzer per file when it throws (#90)
PSScriptAnalyzer 1.25.0 still throws `Object reference not set to an
instance of an object` intermittently even one file per call; it failed
the format gate of the v1.0.2-beta.1 release run (38074357696) until a
re-run. Each file is now analyzed up to three times (2 s pause, a
warning annotation per retry) and only a file that keeps throwing fails
the gate, with the analyzer's error. Findings still fail as before.

Closes #89 (part of #62).

Tested: `actionlint` clean; the loop run locally over the same scripts
reports 0 findings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:16:12 -05:00
james.maesandClaude Fable 5.1 e63b64fb89 fix(release): keep build and publish on one resource configuration; smoke test what ships (#88)
## What

v1.0.1 and the beta.3 rehearsal crash at startup (`0xc000027b` in
`Microsoft.UI.Xaml.dll`). Root cause in #87: the shared release workflow
builds Release (framework-dependent) and then publishes self-contained
into the same `obj`, and the incremental `_GenerateProjectPriFileCore`
target skipped because its new inputs (the Windows App SDK `.pri` files
from the NuGet cache) were older than the index it had already written.
The published `gclo.pri` was 115 KB instead of 2.3 MB and lacked the
WinUI theme resources.

- `gclo/gclo.csproj`: `WindowsPackageType=None` and
`WindowsAppSDKSelfContained=true` unconditionally. gclo ships only
unpackaged and self-contained, so build and publish now share one
resource configuration and the incremental index is always right.
- `release.yml`: new `smoke` job between the gates and the shared
release. It restores, builds Release and publishes with the shared
workflow's exact commands (same incremental `obj` state), then runs the
FlaUI suite against the published `gclo.exe` through `GCLO_UITEST_EXE`.
`release` needs it, so a build that does not start can never be
published again.
- `docs/RELEASING.md`: the smoke job in "Watch the workflow".

Closes #87 (part of #62). Shared-workflow follow-up filed as
KofTwentyTwo/standards#22.

## Done outside this PR

- v1.0.1 marked prerelease with a warning in its notes ("A broken
release" runbook), so stable installs stop receiving it.
- 1.0.0 reinstalled on the maintainer's machine.

## How it was tested

- From clean `bin`/`obj` with the workflow's exact sequence (locked
restore, `dotnet build gclo.slnx -c Release -p:Platform=x64`, the
publish command): before the csproj change `gclo.pri` = 115,816 bytes
and the app exits `0xC000027B`; after it the build and the publish both
give 2,298,032 bytes and the published app opens its main window.
- `actionlint` clean.
- Next: tag `v1.0.2-beta.1`, **install it on a clean machine**, then
`v1.0.2`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
v1.0.2-beta.1
2026-10-10 13:04:22 -05:00
james.maesandClaude Fable 5.1 7511d4568d ci(release): open the packaging PR with a release GitHub App token (#86)
## What

`protect-main` requires signed commits, and the `build(packaging): …` PR
the `packages` job opens had an unsigned commit. #82's DCO sign-off was
not enough: `peter-evans/create-pull-request` signs only with a bot
token and explicitly not with a PAT, which is why the v1.0.1 packaging
PR went #80 → #83 (re-created unsigned by "update branch") → #84
(cherry-picked and signed by hand).

- `packages` now mints a short-lived token from a release GitHub App
(`actions/create-github-app-token` v3.2.0, SHA-pinned) using the
repository variable `RELEASE_APP_CLIENT_ID` and the
`release`-environment secret `RELEASE_APP_PRIVATE_KEY` (contents + pull
requests write, this repository only, no bypass), and opens the PR with
it and `sign-commits: true`. GitHub signs the commit, its author is the
App's `[bot]` user (exempt from `pr / dco`), and the PR still triggers
the required checks. Same pattern as the standards repository's own
release automation.
- `DEPS_PAT` is no longer used by the release workflow (it stays
optional for `nuget-updates.yml`). The preflight step fails when the App
id or key is missing.
- `docs/RELEASING.md` (tokens table + "Why a GitHub App" with the
one-time setup), ADR 0003 and the threat model revision table record the
change.

Closes #85 (follows #81, part of #62).

## Owner steps before the next stable release

1. Create a GitHub App (Settings → Developer settings → GitHub Apps):
installed **only on KofTwentyTwo/gclo**, permissions **Contents: write**
and **Pull requests: write**, no ruleset bypass.
2. Repository variable `RELEASE_APP_CLIENT_ID` = the App's client id.
3. `release` environment secret `RELEASE_APP_PRIVATE_KEY` = the App's
private key (`.pem`), e.g. `op read "op://<vault>/<item>/private key" |
gh secret set RELEASE_APP_PRIVATE_KEY --env release --repo
KofTwentyTwo/gclo`.

## How it was tested

- `actionlint` clean; `create-github-app-token@bcd2ba4` is the `v3.2.0`
tag and exposes `client-id`, `private-key`, `permission-contents`,
`permission-pull-requests`.
- Not exercised on a tag: the next stable release is the first run. If
the App is not set up by then, the `packages` job fails at its preflight
and can be re-run alone after the secrets are added.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 12:24:57 -05:00
james.maes 34b6870f3d build(packaging): point the Scoop and Chocolatey manifests at v1.0.1 (#84)
Generated by the Release workflow for tag `v1.0.1`.

- `bucket/gclo.json` (Scoop) and `packaging/chocolatey` now point at
  `gclo-cli-win-x64-1.0.1.zip`
(SHA-256
`641ae4100022fba3fe489f8abe6ccc43186c070087d3edbf3a7891d5dc7428f7`).
- `scoop update gclo` picks this up as soon as this PR is merged (Scoop
  reads the manifest from `main`); the Chocolatey package was pushed to
  chocolatey.org.

Relates to #9.

Signed-off-by: James Maes <james@kof22.com>
2026-10-10 12:14:15 -05:00