Changelog

Release notes. For the full commit history, see GitHub.

"Fast on large repositories" has been an adjective on the front page since the first commit. It is four reproducible numbers now — and the two worst things those numbers found are fixed in the same release. Opening a file's history walked to the root of history with a tree comparison at every commit, because the limit counted matches and a file with fewer changes than the limit had nothing to stop on: 135.6 seconds and 1,482,923 tree comparisons for one click on a kernel file, all of it holding the lock every other read on that repository queues behind. It is capped at 50,000 commits now, says on screen how far it looked, offers the unbounded walk as a deliberate choice, runs on the shared read lock, and can be stopped. Beside it, the paged log threw away a topological sort it had already paid for and rebuilt it per page, so ten pages into the kernel cost ten times one page; the order is prepared once now and every later page is a slice of it, commit search included. The measurements behind all of that are published in the README and generated from the committed record, so no figure on the front page can be nudged by hand.

New features

  • A file's history says how far it looked, and can be stoppedThe limit on a file history counted matches, not work. A file with fewer changes than the limit had nothing to stop on, so the walk ran from HEAD to the root of history comparing each commit's tree against its parent's at that path — which is most files in most large repositories, not a corner case. There is a visit cap beside the match cap now, defaulting to the newest 50,000 commits, and three things follow from it being visible rather than silent. The screen says which ceiling ended the walk — "Searched the newest 50,000 commits" — and the number it prints comes off the wire with the result rather than out of a constant in the frontend, so it cannot drift away from the policy actually applied. Beside that notice is "Search all of history", which is the old unbounded walk, asked for deliberately, for the case where the answer really is older than fifty thousand commits. And the walk is cancellable, because even capped it is an 18-second wait on a repository that size and a wait you cannot stop is a worse answer than a slower one. The Cancel reaches it from both surfaces that offer one: the status bar, and a new "Stop searching history" row in the command palette. That row exists because a cancel now routes by what can actually stop the operation. "Can this be cancelled" and "what cancels it" used to be two separate answers — a set of cancellable kinds in one file, a hardcoded network cancel at each button — which is free to disagree, and the disagreement's shape is a Cancel that runs and stops nothing. They are one table, because this is the first cancellable operation here that is not a subprocess: a revwalk is stopped by setting a flag it polls between commits, not by signalling a process group, so without the table the palette would have offered "Cancel network operation" for a history search and called a path that reaches nothing. It carries one label rather than the network row's two, for a reason worth stating: a walk polls a flag, so asking twice does exactly what asking once did, and borrowing the network row's "Force stop" would promise an escalation that has no counterpart here. A stopped search says so — "Search stopped. Nothing below it was searched." — rather than showing an empty list that looks like an answer.

Performance

  • File history is capped, and no longer blocks the repository while it runsMeasured on a clone of torvalds/linux, warm, for one click on arch/powerpc/kernel/iommu.c: the uncapped walk compared all 1,482,923 commits and cost 135.6 seconds; capped at 50,000 visits it costs 18.3 seconds. The cap removes about 117 of those seconds, and what remains is dominated by something the cap cannot touch — 14.5 s of it is the revwalk's own preparation, before any tree work at all, with only 4.1 s being the 50,000 tree comparisons themselves. The second half of the fix is the lock. This was the longest read in the backend and it held the EXCLUSIVE handle for all of it, so a single click queued every other operation on that repository behind a walk that could take minutes — precisely the failure the read/write split was built to remove, surviving in the one operation least able to afford it. It moves to the shared read path, which is safe to assert rather than assume: it is a revwalk, a commit lookup, a tree path lookup and a tree-to-tree diff, it writes nothing, and — the part that has bitten this codebase before — it never reads the index, so there is no incidental index refresh for anything else to have quietly depended on.
  • The paged log prepares its sort once, commit search includedThe log rebuilt its revwalk on every page, and in libgit2 a topologically sorted walk is not incremental in any sense: setting a sort order marks the walk limited, so preparation runs the whole reachable graph and materialises the complete ordered list before the first object id comes out. Asking that walk for 500 commits and asking it for all 1,482,923 cost the same thing, so one walk per page paid the identical price per page. It showed as a per-page cost flat in depth — on the kernel, page one cost 15.95 s and page ten 157.67 s, exactly ten times one page, which is the signature of restarting a walk rather than continuing one. git pays for that sort once and then skips. The fix rests on one measurement that is the opposite of what "just cache it" usually costs: draining a walk that has already been prepared is very nearly free. On the 50,000-commit fixture, preparing and taking 500 costs 560.2 ms and draining the other 49,500 costs 1.9 ms; on the kernel, 31.8 s and 63.4 ms for the remaining 1,482,423. Two tenths of one percent more buys the entire order — so the order is computed once, kept as a list of object ids, and every later page is a slice of it. What is kept is deliberately the finished order rather than the live walk, because holding the walk would mean holding a repository handle alive between calls, outliving the lock acquisition that orders every access to it. The ref map that decorates those rows went the same way; it was enumerated and peeled per page to label 500 commits, sixteen times git's own work on a repository with 7,001 refs. Two counter-intuitive things fell out of fixing it. The peeling was never the expense — one enumeration costs 113 ms and the peel inside it is 10 ms of that — so revalidating per scroll spent 113 ms of a 117 ms page on a question that could not have changed. And computing a fingerprint before building the map on a cold cache made things worse, not better: it enumerates every ref twice for an answer that cannot match anything, which made a cold first screen on that fixture slower than the code it replaced. Invalidation is two different stories on purpose. A first page is keyed by the refspec and the starting commits, so any ref that moves is a different key and misses. A continuation is keyed by the frontier it was emitted with and does not consult refs at all — resuming from a cursor never did, and the set a frontier reaches is made of commits, which are immutable. The ref map is keyed by a fingerprint of every ref name and target, so a git tag typed in a terminal invalidates it exactly as a tag made in the app does. The whole thing is a pure accelerator: everything in it is derivable from disk, a poisoned lock degrades to a miss rather than failing a page, and closing a repository drops it. Commit search reads the same prepared order, which is where it was worst — a search that matches nothing recent walks a long way before it fills a page, and the next page threw that walk away. One limit there is deliberate and written down: a search visits far more commits than it returns, so only an order covering all of history can serve one, and a repository past the ceiling falls back to the walk-per-page it always had rather than to a short page that would read as "no more matches exist". One behaviour is now visible that was always true: when two commits share a second, which lane comes first is not something either walk promises, because libgit2 orders its topological queue through a heap with an unstable comparator and a walk resumed from a cursor inserts differently from one that ran straight through. What holds either way — every commit exactly once, and no parent before its child — is asserted against a fixture built entirely inside one second.

Improvements

  • The speed claim is a number somebody else can check"Slow on big repositories" is the most consistent structural complaint about every established git GUI, and being fast is one of this project's two strongest claims — with no numbers behind it, so it was an adjective. pnpm bench now builds three deterministic fixtures in about a minute and drives the real git backend through them: deep (50,000 commits), wide (50,000 files all modified plus 5,000 untracked), and refs (5,001 branches and 2,000 tags). They generate from a fast-import stream with seeded content, a fixed epoch and a fixed author, so the same parameters produce the same object ids on any machine, and each isolates one dimension — breadth hurts differently from depth, and one combined fixture would give a number that cannot say which dimension moved. A real torvalds/linux clone is the opt-in fourth, because a synthetic repository cannot stand in for 1.5 million real commits. Three decisions carry the rest. The composite is the point: the first-screen figure issues the eleven reads the app issues when it opens a repository, simultaneously, behind a barrier — the only shape that can catch "one slow read blocks everything else on this repository", which an operation-at-a-time benchmark is structurally blind to. Baselines ask the same question rather than the cheapest one sharing a name: status returns per-file line counts, so its baseline is git status plus both --numstat diffs, and the log baselines are --topo-order because the graph's lanes depend on that ordering — the first draft used a plain git log and made our first page look fourteen times slower than git, when it is at parity. And repeats are time-boxed rather than counted, because ten repeats of an eight-second log page is thirteen minutes for one table row. What it found: the whole first screen costs 253 ms on a 50,000-commit repository and 219 ms on one with 7,001 refs, opening a repository is 0.11 ms, and on the wide fixture the eleven concurrent reads (5.42 s) cost exactly what the slowest one costs alone rather than the sum of all eleven — which is the read/write split doing its job and the single result most worth not regressing. A ten-minute soak ran 2,344 fan-outs with memory flat between 67 and 69 MB and the median identical in both halves, which answers the "it gets janky after a while" complaint with data rather than assurance. The bad case is published rather than buried: the kernel opens in 15.84 s, and that row leads the README block. The block itself is generated from the committed record by the same script that renders the documentation tables, and the build fails both when the rendered block disagrees with the record and when a measured figure is typed by hand into the prose around it — because a hand-typed figure on the front page is the one that stops moving on the next run.
  • The website's figures are rendered from the real app, at 2xThe three figures on the site were 1x masters captured on 2026-08-18, so every Retina visitor was shown an upscale of 1x text, and 112 commits to the app had landed since — including the icon-set swap, which retired every icon in them. The manual capture path could not fix that: it needed a human clicking a real window, and its resize target disagreed with its own aspect gate by construction. They are rendered now — the real components, in headless Chrome at device scale factor 2, with the desktop surface aliased to a single shim, and the drop shadow and traffic lights composited afterwards because those are the only pixels the operating system contributes to a window whose titlebar the app draws itself. The scene fixtures are typed against the app's own backend types, so a shape change there fails the type-check instead of quietly rendering a picture of a product that no longer exists, which is how the previous hand-built replica died. Two bugs surfaced while wiring it up, both invisible until now. The 2x variant never reached the browser: the component probed for the @2x file through a URL that at build time points at the built chunk rather than the source, so it looked in the wrong directory and no figure has ever shipped a srcset — the probe looked fine because there was no 2x master for it to find. And the image loader both site scripts had copied looked only for the old entry point of the image library, which moved in its current major, so the screenshot script failed outright on any machine that resolved the newer version.

Known limitations

  • The published benchmark numbers predate the log cacheNew in this release, and deliberate. The record behind the README block and the performance documentation was measured before the paged-log fix landed, and it was not re-measured for it. The reason is that two sessions were benchmarking this repository at the same time against one shared cache directory, and control rows — operations neither change touches — moved 25 to 30 percent between runs. That is two programs sharing a machine, not a result, and publishing it as one would have been worse than publishing a stale figure. So the numbers understate the app on exactly the rows the fix improves: the kernel's "ten pages into history" figure is the before. Re-measurement waits for a quiet machine and has to cover all four fixtures at once, because the renderer publishes every result it finds — which is what stops a single-fixture run from deleting the kernel from the record, and also what would let one quietly refresh a fixture nobody re-ran.
  • Opening a 1.5-million-commit repository still costs about 15 secondsThe cap and the cache both stop at the same floor, and it is worth naming because the obvious optimisation is a dead end. A sorted libgit2 revwalk pre-walks the whole graph before it yields anything: on the kernel, getting the FIRST commit out costs 14.2 s, walking 50,000 costs 14.2 s, and walking all 1,482,923 costs 14.7 s — the same number three times, because the traversal has already happened by the time the first one comes back. The sort order is not the reason. Time ordering and time-plus-topological ordering measure within one percent of each other, so dropping the topological sort buys nothing. Unsorted walking IS incremental and is unusable here: it yields commits in the order the traversal reaches them, so the first 50,000 are not the newest 50,000, and a capped file history could miss last week's change while reporting one from 2011. git escapes this with a commit-graph file, which libgit2's revwalk does not read.
  • Two windows on one repository do not share a lockUnchanged from 0.7.0. Each window opens its own handles for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using. The read/write gate orders one window's work against itself, not one window's against another's, so work you start on the same repository from two windows is still arbitrated by git's own index.lock, exactly as it is between any two git processes.
  • A Store update lands hours after the release, not with itUnchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

One density toggle becomes two controls. Text size and Spacing are independent now, so the layout can loosen without the type growing, or the type can grow without the layout loosening — and Zoom stays as the third knob, because scaling everything including chrome and borders is a different question from either. One thing changes without being asked: an install that sat on Compact density moves to Cozy, two pixels per row, reversible in a click. Beside it, the fix behind a report that reads like a webview bug and is not one — the Commit tab's diff flickered and would not stay scrolled, because a filesystem watcher and a staging-state dependency are each correct alone and together refetched the whole file on every save. Under both, two high-severity advisories that no alert had ever raised, found by auditing this tree for the first time.

New features

  • Text size and Spacing, in place of one density toggleUI density was a single binary — Compact or Comfortable, one four-pixel step added to every row — which answers two questions with one control and therefore answers neither. It is two now. Text size runs Small, Default, Large, Larger and scales the whole type ramp by 0.92, 1, 1.15 and 1.3; Spacing runs Compact, Cozy, Comfortable, Spacious and adds 0, 2, 4 and 8 pixels of breathing room per row. Zoom is untouched and stays as a third knob, and the division between the three is deliberate: Zoom scales everything, chrome and icons and borders included; Text size scales type, the row bases that have to hold it, and the column widths that are derived from text; Spacing scales nothing but the room around a row. Each answers something the other two cannot, which is why all three exist rather than one being folded into another. One default moves for existing installs: a stored Compact becomes Cozy. That is the release's only unasked-for behaviour change and it is a decision rather than an oversight — a stored Compact cannot be told apart from never having opened the setting, because loading fills missing keys from the defaults and writes the result straight back, so grandfathering it would have shipped the roomier default to new installs only, which is to say to nobody who had already formed the opinion that prompted the change. Two pixels per row, one click back. Comfortable stays Comfortable, and Text size defaults to Default everywhere, so nobody's type changes size without being asked. Two things underneath are load-bearing. The ten type tokens are written as resolved pixel values computed in JavaScript, plus one unitless row scale, rather than as a calc() in CSS — a calc() there would nest inside an unregistered custom property, which is exactly the case the diff row height already carries a fallback for. And every row call site reads its base multiplied by the scale with the step added, never the other way round: the step is already the user's own pixel count and must not be scaled, while the base is the thing that has to hold the text. The commit row's text-sized columns became functions of the scale for the same reason — the sha column is literally seven hex digits of monospace, and truncating a sha destroys its meaning rather than shortening it — and the list's minimum width scales with the columns it is the sum of, so the Date column cannot be pushed off the right edge at Larger. Four guards fail the build rather than leaving any of that to review: a row surface that takes one scale without the other or multiplies twice; a type ramp that stops ascending at some preset; a row base that no longer clears its own line box; and column minimums that no longer fit the narrowest pane, re-derived from the grid template the app actually emits rather than from the formula being checked. Both settings normalizers also reject an inherited object property, which a looser check would have waved through into an undefined row height for every row in the app. The pairing that matters most is only observable in a real webview, since jsdom cannot resolve a calc() at all: type growing while the box that holds it does not.

Fixes

  • The Commit tab's diff no longer blanks out or loses your placeReported from WSL2, and the webview is not the cause. Two correct changes met. The commit panel's diff effect depends on the status array, as the signal that the selected file's staging state moved, and that dependency is load-bearing: without it the pane kept showing the pre-stage diff while the next line selection addressed indices into it, staging lines other than the highlighted ones. But a status refresh replaces that array wholesale, and the filesystem watcher runs one refresh per filesystem event — so every event refetched the diff. Meanwhile the pane's body was gated on "a fetch is in flight" and swapped its rows for a spinner. The scroll container itself stays mounted, so its content collapsed from a whole file to about fifty pixels, the engine clamped the scroll offset to zero, and putting the rows back did not put the reader back, because restoring content never restores a clamped offset. That is both halves of the report from one mechanism: it flickers, and it will not stay scrolled. The body is gated on "waiting with nothing to show" now, so refetching the file already on screen keeps its rows, its height and your position, and only a first open or a switch to another file shows a spinner. The refetch itself deliberately stays: a file can be edited to different text with every status field identical — change one character on an already-modified line and none of the counts move — so "nothing in the status changed" is not "the diff is still right", and suppressing the fetch would have traded a visible flicker for a silently stale diff, which is the opposite of what the watcher exists to provide. Measured on WebKitGTK 605 against one event that changed nothing at all, same bytes and a new mtime: before, the pane went from a scroll offset of 3460 with 52 rows to no rows at offset zero; after, 122 frames with not one blank frame, 52 rows throughout and a single scroll position. The reporter's own screen capture showed 13 fully blank episodes in 7.7 seconds. The repo browser and the Diff screen never had this, because their diff effects key on the selection's primitives rather than on the status array — the commit panel is the only surface that refetches on a status refresh, which is why the report named the Commit tab.
  • The theme editor's colour section no longer sidescrollsNew in 0.10.0 and visible the moment the section was scrolled. The block holding all eighteen colour fields carried a negative margin on each side, written as a bleed out to the modal's own padding — but it is not a child of the modal. It is a child of the controls column, and that column scrolls. A stretched flex child with sixteen pixels of bleed on each side is thirty-two pixels wider than its own scroll port, so the section grew a horizontal scrollbar: measured at a scroll width of 440 against a 424-pixel port. The bleed is gone, and the horizontal padding comes off the colour editor instead, so the colour rows now line up with the Palette card above them rather than sitting inset from it. A second cause sat in the same block: the colour grid's track floor was a flat 190 pixels, and an auto-fill track keeps its floor even when the container is narrower than it, which sidescrolled the section by another 38 pixels once the column dropped below that width. Verified by measuring scroll width against client width in a real browser at three column widths, including the narrowest the window allows — sixteen pixels of overflow before, none at any of the three after. jsdom has no layout and cannot see a scrollbar, so the two guard tests pin the two mechanisms instead, and the bleed guard exempts positioned elements, since the Tint slider centres its thumb with a negative margin and widens nothing.

Build & packaging

  • Two high-severity advisories that no alert had raisedRUSTSEC-2026-0194 and RUSTSEC-2026-0195, both scored 7.5, against the XML reader that reaches the shipped binary through tauri's property-list parser: quadratic run time while checking a start tag for duplicate attribute names, and unbounded namespace-declaration allocation, which is a memory-exhaustion denial of service. Neither had a Dependabot alert. They surfaced from running cargo audit against this tree for the first time — GitHub's advisory database and the RustSec database do not agree with each other, and this is what fell through the gap. Worth remembering the next time "no open alerts" gets read as "nothing to fix". The fix needed no manifest edit, because tauri's own range already admitted a newer property-list crate: updating that one crate carries the XML reader past the version both advisories ask for, and the change is the lockfile and nothing else. Verified as a move from two vulnerabilities found to none, with the Rust suite unchanged at 1334 passing.
  • Dependency advisories are reported on a schedule nowNo workflow here had ever run an audit, so the security tab was the only thing that knew — and it knew badly, because the security updater only bumps a manifest entry while every npm advisory this repo receives is transitive-only. For most of them it opens nothing at all and the alert simply accumulates; twenty-four had. A scheduled job reports them now, and it is deliberately not a gate: there is no pull-request trigger on it whatsoever, which is the only reliable way to stop a reporting job quietly becoming one, and the Linux e2e run stays the single required check. What can fail is narrow. On the npm side only the production-scope audit fails, since that is what the shipped bundle is built from, while the all-scopes run reports into the job summary and passes. On the cargo side the default fails, because those crates ship. There is no ignore list, and that is a measurement rather than an omission: the cargo audit fails on vulnerabilities and merely warns on unmaintained or unsound crates, so the eleven warnings here — including the one that ships and is blocked upstream — pass on their own. It is also why that step must never be made to deny warnings, which would leave it permanently red over advisories nobody can act on. The site's own dependencies are out of scope on purpose: they are direct, so the grouped monthly proposal already fixes them.
  • The September dependency batch, and a TOML denial of serviceSix dependency proposals folded into one branch, which is the measured-cheaper shape here — the lockfile's peer fanout conflicts every other npm proposal on each merge, so merging them one at a time costs a rebase and a CI run apiece. React 19.3, lucide 1.45, the WebdriverIO packages, and Vite 8, which swaps rollup for rolldown: the React plugin already declared the new major so it needs no companion bump, the esbuild floor that closes an older advisory is held by the same range as before, and the production bundle builds clean with an unchanged entry chunk. With them, a high-severity denial of service from malformed TOML documents, which had been left open for a real reason that has now expired — the vulnerable range was every published version, so there was no number to bump to until the fix shipped. It arrives through a package this repo pins exactly, in order to escape a broken release of that package, so it cannot be bumped out from its parent and an override is the only route. It is guarded as a required key and as a floor, because the key assertion catches the case the floor cannot see: delete the key but leave the lockfile alone and the already-resolved version still satisfies its parent's own range, so the floor passes vacuously while the advisory is re-opened in silence. One proposal was declined rather than taken. The Node type definitions stay on 22, because they describe the runtime rather than a library: every workflow, the e2e container and the documented toolchain are pinned to Node 22, so types describing Node 26 would let code type-check against APIs that exist nowhere this app is built, tested or shipped, with the type-checker green about it. That decision is recorded as an ignore rule at semver-major only, so patches still flow and the weekly re-proposal stops.

Known limitations

  • Two windows on one repository do not share a lockUnchanged from 0.7.0. Each window opens its own handles for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using. The read/write gate orders one window's work against itself, not one window's against another's, so work you start on the same repository from two windows is still arbitrated by git's own index.lock, exactly as it is between any two git processes.
  • A Store update lands hours after the release, not with itUnchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

The theme editor stops being a column of eighteen colour fields and becomes somewhere you can actually design. The colour picker belongs to the app now rather than to the operating system — a hue wheel, a brightness slider and four colour models, instead of whatever panel the host happened to supply. Above it, four traits generate the whole palette: a base ramp, an accent, a harmony and how far the hue reaches the surfaces, each with a lock and a dice that re-rolls the rest. And creating a theme is a button rather than a copy of somebody else's. Away from Settings, the release carries a fix worth reading: a branch or tag whose name begins with a plus sign was handed to git as a force refspec, so pushing it force-updated a different ref and destroyed history on the remote with none of the app's rewrite warnings.

New features

  • A colour picker that belongs to the appAll nineteen colour fields in the theme editor were native <input type="color"> controls, which is less a picker than a hand-off to whatever dialog the host supplies: an unstyled system panel, so a dark theme's greys are chosen in a bright window, offering hex plus the host's own colour model and nothing that helps build the ramp a theme actually is. They are a hue and saturation wheel now, with a brightness slider, a hex field, and a numeric row that switches between HSV, HSL, RGB and OKLCH. The popover also carries the draft's own palette as swatches, a row of recent colours, a live contrast ratio for a slot that is paired with another, and copy and paste on the swatch's right-click menu. Looks were not the only reason to replace it: a host dialog is untestable by construction — it could stop working entirely with the whole suite green — and it belongs to the same class of native control that turned out to be silently inert on Linux in 0.9.0. Four things in it are load-bearing, and each is a bug the obvious version ships. The state is HSV rather than the colour, because a grey has no hue and black has neither hue nor saturation, so reading the state back out of the colour teleports the wheel cursor to red the instant saturation reaches zero. The selected model's channel values are held rather than re-derived per render, because re-deriving quantizes them and the error accumulates — ten presses of a one-degree step moved the hue by 10.14° and the readout crept away from the number it had just shown. The gamut epsilon absorbs floating-point noise and nothing more, because a looser one reads a visible chroma as representable at pure black, which is exactly where a theme keeps its greys. And the outside-press handler knows the right-click menu is portalled, or that menu's own press closes the popover and lands its click on a detached node.
  • A palette built from four traits, and a diceEighteen colour slots are a lot to choose one at a time, so they are generated from four traits you can reason about: a base ramp, which supplies every slot's lightness and its baseline chroma; an accent, the one colour you pick, which becomes the accent verbatim; a harmony — Mono, Analogous, Triadic, Split or Complementary — deciding where the surfaces and the logo pair sit relative to that accent; and a tint from 0 to 1, how far the palette's hue reaches the surfaces. Each trait has a lock, and one dice re-rolls everything unlocked. The eighteen slots stay hand-editable underneath; a hand edit just flips the readout to Custom. This reverses a non-goal that was written down twice, and deliberately: the objection was to a generator in HSL, where hue and lightness are one knob so a generated ramp's contrast lands wherever it lands. Holding lightness and rewriting only hue and chroma in OKLCh makes a generated palette both predictable and correctable, and that is measured rather than asserted — across all nine built-in themes and all five rules, a tint of 0 reproduces the original palette byte for byte, and across 3240 checks of nine themes against twenty-four hues, five strengths and the three pairs that decide readability, tinting never once moved a contrast verdict into a different band. The dice cannot roll an unusable theme either, because those four traits are its only inputs and each rolls inside a band measured off the built-ins. Nothing about the saved format changes: the traits are inferred from the palette when you open it and never stored. Diff and syntax colours are deliberately out of scope — diff green carries meaning, and a palette choice must not change what a diff says.
  • Add a theme without duplicating one firstCreating a custom theme was reachable only as find a card, press Duplicate — which spells a create as a copy, and makes the starting palette a decision you have to take before you have a draft to look at. There is an Add theme button under the gallery now, beside Import. It opens the editor on the theme you are using, and the editor's own Start from picker is where you change what the draft begins from, so that choice happens inside the dialog with a live preview already in front of you. The draft's name follows the base while it is still the automatic one, which closes a quiet way to mislabel a theme: switching the base used to swap the whole palette and leave the name behind, so a draft could save as Dracula (custom) while being built entirely out of Solarized. Nothing overwrites a name you typed yourself, and editing an existing theme never renames it.

Fixes

  • A branch whose name began with a plus sign force-pushed a different branchThe most serious fix in this release, and it destroyed history rather than merely refusing to work. A ref is sent to git in refspec position, and a leading plus sign there means force-update — so selecting a branch or tag called +main and pressing Push made git force-update main, discarding whatever the remote had, and never touched the ref actually selected. Nothing warned, because as far as the app was concerned this was an ordinary push: the rewrite confirmation that every history-rewriting entry shares never ran. Measured against git 2.50.1 on a diverged remote, the push reported a forced update of main and created no +main at all. The end-of-options separator added elsewhere in this release does not help — it ends option parsing, while this is the refspec grammar underneath it — and validating the name cannot help either, since a plus sign is legal in a ref name and git will happily create the branch. The fix names the ref in full on both sides of the refspec, so no character of a user's name is ever the first character of the refspec and the ambiguity is removed rather than detected. Pull was in the same class and had not been reported: the branch is a refspec there too, so pulling +main fetched and merged main instead. Naming the ref in full was checked to be equivalent to the bare name for setting upstream, force-with-lease, force, branch creation, slashed names and all three pull modes, down to the auto-generated merge subject; it also settles two cases the bare name got wrong, since a +main branch now reaches its real ref and a repository holding both a branch and a tag of the same name pushes the branch instead of failing.
  • Checking out a remote branch now actually tracks the remoteChecking out origin/x as a new local branch produced a branch that tracked nothing — no ahead or behind, no pull, and nothing to say it had fallen behind — while the prompt said Tracking origin/x. Worse, when the name was already taken the create failed, the failure was reported by a banner, and the checkout then ran anyway on whatever unrelated local branch already held that name, its refresh wiping the banner on the way past: you asked for the remote branch and landed on a stale local one with nothing on screen to say so. Creating a branch now sets the upstream when the start point resolves to a remote-tracking branch and only then, which is git's own branch.autoSetupMerge default, measured against real git rather than assumed — and doing it in the backend means every route that branches off a remote gets tracking, not just this one. A taken name is a question now rather than a silent substitution, offering to check the existing branch out and update it to the remote, to check it out as it is, or to pick a different name; the update is offered only when the branch is strictly behind and the ref it would advance along is the one you named, because a branch tracking something else would move along a ref the dialog never mentioned. Two adjacent surfaces in the same story were broken too: the Branches detail pane's Check out button answered a remote ref with an invalid-ref error, and Enter on a remote row did nothing at all.
  • Every remote and branch name now ends git's option parsingPush and pull were the last argv sites handing a user-supplied remote or branch to git without the -- separator that the fetch, tag-push and delete-push builders had always carried, so a remote named like an option could be read as one. Both push builders emit it now, with every flag of ours moved ahead of it — which is why they lacked it before, since a force flag appended after the separator would be read as a refspec rather than an option. Pull is not the same problem and the separator alone does not fix it: git pull parses its own options, consumes the separator, then re-runs fetch with no separator of its own, so the value arrives as an option after all and a crafted remote can run a program of its own choosing. Pull therefore refuses a remote or branch that begins with a dash outright, and still emits the separator, which does end its own option parsing. The same refusal covers git-LFS fetch and pull, where a separate binary's flag parser is its behaviour to confirm rather than ours to assume. One consequence was followed up: a pull refused on those grounds never reaches git, so the working tree that was stashed before the call is now popped back — a refused argument must not cost you your uncommitted work, parked in a stash you were never told about.
  • Settings rows follow the UI density settingEvery other list surface in the app scales with the UI density setting; the rows inside Settings did not, so the side menu grew and the panel it navigates stayed put. Settings rows, the forge account rows beside them and the theme gallery's Add and Import strip all scale now, from one shared value rather than three copies of the same expression — copies being how a forge account row ends up a few pixels off from the setting above it while every test still passes. A card's header keeps its fixed padding, since chrome is exempt from the density rule, and that exemption is now pinned by a test rather than left as a comment. The step is also measured in a real webview, because the layout arithmetic involved is not something the unit test environment computes: it could only check that the expression was present, and dropping half of it grows a row by twice the intended amount while staying green.

Build & packaging

  • A dependency advisory closed by removing the packageA high-severity path-traversal advisory against extract-zip had no patched version to move to: 2.0.1 is the latest release, from 2023, and the upstream fix was never published. It is closed by deleting the package from the tree instead — a newer major of the browser downloader that pulled it in dropped the dependency, and an override forces that major across the one remaining request for the old one. This is test tooling rather than anything the app ships, and the guard is written as absence rather than as a version floor, since floors are keyed by major and a dropped override letting the old version back in would satisfy a 3.x floor vacuously and re-open the advisory in silence.

Known limitations

  • Two windows on one repository do not share a lockUnchanged from 0.7.0. Each window opens its own handles for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using. The read/write gate orders one window's work against itself, not one window's against another's, so work you start on the same repository from two windows is still arbitrated by git's own index.lock, exactly as it is between any two git processes.
  • A Store update lands hours after the release, not with itUnchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

The right-click menu on a commit is the release: eight new entries take it to parity with the JetBrains menu it was audited against — reword, undo and drop a commit, browse the repository as it stood at that revision, walk to a parent or child, open the commit on GitHub or GitLab, export a patch series, and publish history only as far as one commit. When something does go wrong there is now a way to say so from inside the app: Report an issue assembles the report, shows you every character of it, copies it and opens a prefilled GitHub issue. And every glyph in the app was replaced — the hand-drawn set is gone, lucide is in, so a branch looks like a branch and a tag looks like a tag.

New features

  • Reword, undo and drop a commit from the History menuThree entries that had engine support and no way to reach it. Edit commit message… on HEAD is a message-only amend rather than a one-step rebase, which matters because the rebase engine refuses any modified worktree or index — routing it through the engine would have failed for anyone with uncommitted work, which is most reword attempts. The amend reuses the commit's original tree, so staged changes cannot be folded in behind your back; the author is preserved, the committer refreshed, and it goes through the one signing chain so a signed commit stays signed and a signing failure creates nothing. An older commit still goes through the engine. All five history-rewriting entries — reword, undo, drop, squash and fixup — now share one confirmation that names the force-push when the commit is already on the upstream; right-click Fixup previously ran with no dialog at all.
  • Browse the repository at a revision, and walk the graphThe repository browser has been able to read a tree at any revision since it shipped, and the only way in was its own toolbar picker — so Show repository at this revision is the entry point a commit never had. Unlike every rewrite entry it is offered for a commit on any branch, because reading a tree writes nothing and ancestry is irrelevant. Go to parent and Go to child commit move the selection along the graph; one target goes inline, several give a submenu, because a merge has two parents and a branch point two children and picking one silently is a guess presented as a fact. The child entry blames the loaded log rather than the repository when it finds nothing — the log is paged, so "no child loaded" and "no child exists" are different sentences and only one of them is honest.
  • View a commit on GitHub or GitLabView in browser opens the commit's page on the forge. No network call and no token: the URL is built from your own remote, so it works for a forge you have never signed into, and nothing is sent — the app derives a string and hands it to your browser. The GitLab case is the one with a trap in it: GitLab's API takes a project path percent-encoded whole, slashes included, but a browser needs real separators, so a subgroup path stays several path segments instead of becoming one unvisitable blob. On GitHub the page is on the remote's host, not api.github.com, which would have shown a reader JSON. A repository with no derivable page says why and points at the Settings host mapping that makes a self-hosted instance work.
  • Create patch files from a commit or a selectionCreate patch… writes a git format-patch series into a directory you pick — mailbox format, not a plain diff, so the files carry author, date and full message and git am reconstructs the commit rather than only its changes. A merge is refused before anything is written, and a merge anywhere in a multi-commit selection refuses the whole export: format-patch skips merges silently, so a partial series would hand you a shorter list with nothing naming the commit that vanished. The series is numbered in the order given, oldest first.
  • Push history up to one commitPush all up to here… publishes your branch only as far as the commit you picked and leaves the rest local, through a refspec push — an ordinary push sends whatever the branch points at, with no way to say "stop here". The confirmation carries both counts ("pushes 3 of your 7 commits"), because the label cannot say how much of the branch it covers and that is the entire question; when a count cannot be read the sentence omits the numbers rather than inventing them. Fast-forward only by construction — there is no force variant on this path. A commit outside HEAD's ancestry is refused, and so is a branch with no upstream, each saying which.
  • Report an issue from inside the appA bug report is assembled for you — a summary you write, the environment (version, OS, architecture, git version) and the tail of the log — then shown in full, copied to your clipboard, and a prefilled GitHub issue opens in your browser. Four ways in: the titlebar's bug button, any error banner, Settings → Backup & diagnostics, and the crash screen. That last one is the reason this exists in the shape it does: after a render throw React has unmounted the dialog host, so the error boundary cannot open a dialog at all and instead runs the whole flow with no React tree under it. The log travels by clipboard rather than in the URL because a GitHub issues/new?body=… answers HTTP 414 somewhere around 8 KB — it works in testing, where the log is short, and fails on exactly the machine that has been running long enough to have a bug worth reporting. Copy happens strictly before open: sending someone to a form that says "paste your report" with an empty clipboard is worse than saying nothing. Nothing is ever transmitted — the app writes your clipboard and hands a URL to your browser — and because the report is the most revealing text the app assembles in one place, the preview shows the exact string that will be copied and each part is independently opt-out, filtering the clipboard rather than only the display.
  • A real icon setEvery glyph in the app now comes from lucide instead of the hand-drawn set, behind the same PGIcon seam — 126 call sites across 54 files are untouched. It was chosen over the alternative for having the full git vocabulary: branch, merge, fork, pull request, tag, terminal, a folder for submodules and a symlinked one for worktrees, where the runner-up ships no folder and no VCS glyph beyond a commit dot, which would have turned about seventeen of this app's names into generic approximations in a git client. Stroke weight is preserved exactly at every size, so the app's visual density is unchanged. The diff-layout toggle gained real panel icons showing the layout currently in effect rather than the same generic glyph in both states, and one long-standing typo surfaced: the Reflog screen's refresh button had been asking for an icon name that did not exist and rendering the unknown-name fallback square.
  • A theme gallery, with a preview of the real thingChoosing a theme used to mean picking a name out of a dropdown and applying it to find out what it looked like. Themes are cards now, each with a live preview rendered from the same map the app itself is painted with — so a light theme's card stays light on a dark page — and each carries its own Edit, Duplicate, Export and Delete next to the theme they act on. The editor is a proper modal with an inline preview of the real UI beside the controls, rather than a palette judged through a dimmed backdrop over the tenth of the app the dialog did not cover. It adds a guided start (base colour plus accent, with button ink recalculated), advisory WCAG contrast warnings on the four pairs that decide readability — Save is never disabled by a finding, a low-contrast theme is your own call — and import into the draft, so round-tripping a theme through an external editor works. Cancel, Escape and the backdrop all restore the theme you had before you started.
  • The history view says when it is waitingChecking out a branch, pulling, and the last stretch of a rebase all take a noticeable moment, and the commit list used to sit there showing the branch you just left — a four-second checkout read as a click that did nothing. A strip above the commit list now carries the operation's label, git's percentage when there is one, an elapsed clock past three seconds, a +N more when operations overlap, and Cancel for the ones that can honour it. It and the status bar render from one hook, which owns every decision about what is said, so the two cannot drift apart.
  • The Refresh button spins while it is refreshingFetch, Pull and Push have always spun; the one button whose entire job is "reload" was the one that never said it was working. It tells two cases apart by who asked: a refresh you asked for spins immediately and holds briefly whatever the backend did — a 40 ms quarter-turn reads as a rendering glitch, not an acknowledgement — while a background refresh spins only once it is slow enough to be worth mentioning. Binding it to the generic loading flag was the obvious fix and is wrong in the other direction: that flag flips on every tab switch and commit, and the titlebar would twitch all day.
  • The built-in terminal is in the command paletteThe terminal panel had exactly one route in — Ctrl and the backtick key — because the palette is a curated list rather than a projection of every action. The row is labelled by state ("Show terminal" / "Hide terminal") on a stable id, so a command's ranking is not split across two half-learned rows, and it appears only with a repository open, since the shell opens in the active repository's working directory.

Fixes

  • Submenu entries did nothing — since the first public release"Reset current branch to here ▸ Hard" did nothing. Neither did any other submenu entry, nor any entry on a branch-picker row menu. A menu is a portal on the document body and dismiss-on-outside-press fires on mousedown, and both surfaces read a press on a menu they own as a press outside themselves — so the menu closed on the press and unmounted the entry before its click could run. The menu vanishing under the cursor is what made it read as "I clicked it and nothing happened". The backend was never reached. The e2e suite was green throughout because it clicked entries with a bare synthetic click and no preceding press, a sequence no mouse can produce; it presses first now and fails loudly if the entry vanishes under it, which is what surfaced the branch-picker half that was not in the original report.
  • Every export in the app silently did nothing on LinuxExporting a theme, exporting your settings and exporting an editor draft all wrote their file the way a web page would — a blob URL and a synthetic click on a download link. WebKitGTK ignores the download attribute, so on Linux all three were a no-op with no file, no error and no log line. A second, independent cause sat underneath: the save dialog had never been granted, so even a correct implementation would have been denied at runtime. A webview is not a browser and nothing about that mechanism is contracted to work in one, so it is replaced rather than patched: there is one native file path now, every export and import goes through it, a save returns the real path so the app can tell you where the file went instead of guessing at a downloads folder it never chose, and a cancelled dialog is a cancellation rather than an error.
  • A click in the branch picker no longer checks out a branchThe titlebar picker spent a single click on a working-tree mutation — one misfire in a list of near-identical names switched branches, while the current branch's row answered a click with nothing at all. Click, Enter and → now all open that row's actions menu, the split the Branches screen already had. Check out is that menu's first entry, so nothing became unreachable; it costs one deliberate second press. The current branch's row now offers everything it can still do — merge, rename, push — rather than nothing.
  • A context menu taller than the window was unreachableThere was no height bound and no scrollbar, and the off-screen correction degenerated for a menu taller than the viewport: it pinned the menu to the top and let the overflow run off the bottom edge with no keyboard route to it. The commit menu is 31 items at its minimum and grows with every branch pointing at the commit and every custom action you define, so it now exceeds any window shorter than about 740 pixels — and *View in browser*, the last entry, was the first thing lost. The bound is unconditional rather than tuned to an item count, and submenus inherit it.
  • A tag on a History row wore a branch iconv1.0.0 looked like a branch sitting next to main, which is the one thing a history view exists to tell apart at a glance. Decorations arrived as bare names, leaving the pill to guess what each was from its spelling — and that guess got three things wrong at once: every tag drew a branch glyph, a tag like release/1.0 and a local branch like feat/x were each split into a remote that does not exist, and the "local labels only" filter then hid both. The log walk sends the kind it already knew now, so a tag gets a tag glyph in the amber tone the tag badge beside it already uses, only a genuine remote gets split, and refs that are neither — a bisect ref, a fetched pull-request head — are named as themselves rather than called branches, which is how one ends up on a menu that would move them.
  • A narrow History pane no longer eats the subject columnEvery column but the subject was a rigid pixel width, so the subject was the only track that could yield and it yielded everything — at the narrowest the pane can be dragged it resolved to 22 pixels of subject against an author name holding its full 150, with the branch pills painting over the author and the header reading SUBJECTAUTHOR. The yield order is in the template now: the author name truncates and then disappears, the avatar still says who, and what a narrow pane costs is the author's name rather than the one column everybody reads. The same repro turned up two defects that were never about narrowness — a long author name ran into the date at any width, and the headers had no clipping either. Reflog needed this most and gets it for free: its list pane is a third of the window, so its subject was under 100 pixels on an ordinary display.
  • An image that will not decode says soImage detection sniffs the first bytes of a file, so a truncated or corrupt image with an intact magic number came back as an image and was handed to a preview that had no error handler — the panel showed the webview's broken-image glyph and said nothing, where every other non-previewable state already says something specific. It now retires that side with a sentence, keeps the byte count (which came from the backend and is still true), and tracks the failure per side, so a corrupt new version still shows the readable old one and the next file starts clean.
  • Three layout fixesThe commit detail's clock icon no longer drops below the timestamp it belongs to; a segmented control's labels stay on one line instead of wrapping; and the activity bar runs to the window's bottom edge rather than stopping short of it.

Known limitations

  • Two windows on one repository do not share a lockUnchanged from 0.7.0. Each window opens its own handles for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using. The read/write gate orders one window's work against itself, not one window's against another's, so work you start on the same repository from two windows is still arbitrated by git's own index.lock, exactly as it is between any two git processes.
  • A Store update lands hours after the release, not with itUnchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

Settings is a screen you navigate rather than a scroll you have to read: ten pages behind a grouped side menu, and a search box that finds a setting by name and hands you the working control. Branch folders finish what they started — drag a branch from one folder into another, fold them away in the titlebar picker — and a custom action can now take a keyboard shortcut. Underneath all of it, the read-only half of the backend stopped queueing behind itself, which is worth milliseconds on a fast repository and minutes on a slow one.

New features

  • Settings you can search, on pages you can navigateThirteen flat cards — thirty-seven fixed rows plus a forge host list — used to render into one 820-pixel scroll, so finding a setting meant scrolling and reading it. There are ten pages now behind a grouped side menu (General, Git, Advanced) with tree keyboard navigation, and typing in the search box returns every matching row from every page under a Group › Page breadcrumb. A result is the real working control rather than a copy of it: the page components themselves mount under a filter, so you change the setting where you found it. Pages are deep-linkable too, so the Pull requests screen's "add a forge token" button lands on Integrations instead of at the top of a scroll. Two rows that were misfiled under Pull & fetch — "Watch the working copy" and "Terminal shell" — moved to a Workspace page, and the page you were last on is remembered per machine and left out of an exported settings file.
  • Drag a branch between folders, and fold the folders awayA branch folder is the / in a branch name rather than a git object, so moving a branch between folders is a rename and nothing else — only the leaf travels, the way a file dragged between directories does, and the drop confirms before it renames, because a stray pointer release must not rename a branch. Dragging a branch *out* of a folder gets a drop bar for the duration of the gesture, and only for a drag it can actually serve. The keyboard equivalent is "Move to folder…" on the branch menu, so it reaches every branch surface rather than only the Branches screen, and it answers through the same legality check as the drop — which is what stops the two from disagreeing about a name that is already taken. The titlebar picker trees its branches now as well, off the same per-repository fold set, so a folder you fold in one place is folded in the other; each section trees on its own, so folding origin cannot fold the local feat beside it. A folder row there is never checkout-able: Enter toggles it, → opens it, ← folds it or climbs out, and the cursor still comes to rest on HEAD — or, when HEAD is folded away, on the folder holding it. Grouping runs last and never sorts, so your branch ordering and your pins are untouched.
  • A custom action can take a keyboard shortcutThe chord lives on the action in your settings file, so it is hand-editable like everything else stored there. Three rules decide whether a shortcut is live. It needs ⌘/Ctrl or a function key — a bare letter would fire while you arrow through a file list and swallow that letter from speed-search, and ⌥ with a character key types on macOS; Mod+Alt+<letter> is refused because that is AltGr on Windows and Linux. The action has to be on the command palette, because a key press carries no selection, so $FILE and $SHA would have nothing behind them. And no built-in may already own the chord, checked against every preset rather than the active one, since presets are switchable and the built-in wins silently. Settings refuses a collision up front and names what already holds the key, so nobody records a shortcut that would simply never fire. A custom chord is offered last, after every built-in on that chord has declined: a setting can never take a key away from the app, even from a hand-edited file. What fires is what is listed — the cheat sheet and the palette chip read the same gate the dispatcher does.

Performance

  • Reading a repository stopped queueing behind itselfEvery read-only operation took the same exclusive per-repository lock, so the eleven reads a refresh issues together waited for one another and you paid their sum instead of the slowest one. Each repository now keeps one cached handle for writes and mints a private handle per read, with a gate ordering the two, and fifteen read-only operations moved onto the shared path. On a warm local repository twelve reads go from 16.9ms to 10.6ms — real, but not something you would feel. The case this was built for is the one where you would: a repository on a Windows drive under WSL, where every stat crosses a VM boundary and the reports were of roughly 9 seconds of reads taking about 108, with arrowing through history laddering to 45. Nothing about write ordering changed — every ordering guarantee this backend documents is about a write, and all of them still run under a gate that excludes everything else. A pre-warmed handle pool was measured and rejected: opening a repository costs about a millisecond, which is on the wrong side of noise.

Fixes

  • A pin follows a branch through a renameA pinned branch was matched by its exact name, so renaming it silently dropped the pin. This fixes renaming from the branch menu as well, not only the new drag.
  • Two surfaces showing the branch tree no longer fight over which folders are foldedFold state was per-hook React state over a shared key, so with the Branches screen and the titlebar picker both rendering the tree, whichever wrote last dropped the other's folds. It is one store now, read through on every change rather than off the render that produced the callback — a fold can be applied after an await.
  • The branch picker's cursor stops jumping while you typeThe effect that clamps the cursor to the list length read its index out of its own render closure, so it could overwrite the resting-position effect in the same commit. It was always latent; the extra folder row made it reachable just by typing a query.

Build & packaging

  • The HTTP client moved to ureq 3A major-version rewrite of the one crate that makes outbound calls — the forge integration and the update check. It is here rather than left silent because of what it would otherwise have changed quietly: ureq 3 collapses a 4xx or 5xx into an error and drops the response body with it, and that body is the difference between a banner reading "forge error: 422" and one reading "a pull request already exists for owner:branch" — and it is what turns a 401 into a prompt to fix your token in Settings instead of the git credential dialog. The forge client therefore classifies statuses itself. The redirect budget and the https-only setting are written down explicitly now rather than inherited from a default that changed between the two versions, and an over-size response body is an error instead of a silent truncation that surfaced later as unparseable JSON. Nine new tests cover the half of this no existing test could see, still with no network anywhere in the suite.
  • The dependency tree caught upCodeMirror, lucide, the Tauri CLI, the updater plugin, serde, jsdom and the React Vite plugin, landed as a single commit — the updater's npm and crate halves are a pair the build hard-errors on when they disagree, so they cannot move one at a time.

Known limitations

  • Two windows on one repository do not share a lockUnchanged from 0.7.0. Each window opens its own handles for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using. The new read/write gate above orders one window's work against itself, not one window's against another's, so work you start on the same repository from two windows is still arbitrated by git's own index.lock, exactly as it is between any two git processes.
  • A Store update lands hours after the release, not with itUnchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

Repositories open in several windows now, not only several tabs — one per monitor, each window the whole app with its own tab strip and its own session. Linux gains arm64: a .deb and an AppImage for aarch64, served from the same APT repository, so a Raspberry Pi or an arm64 cloud desktop installs exactly the way an x86_64 one does. And the diff size ceiling that landed with 0.6.x now takes an answer — a file it refused can be read anyway, on request. One fix here matters more than any of that: staging or discarding a file could untrack or even delete work that had been changed outside the app.

New features

  • Open a repository in a second windowTabs are for switching between repositories; windows are for looking at two of them at once. Every window is the whole app — its own tab strip, its own session, its own repository handles — so two repositories sit side by side, one per monitor, with a long rebase running in one while you work in the other. "New window" is Mod+Shift+D; "Open in new window" and "Move to new window" are on a tab's right-click menu, and all three are in the command palette. Windows are remembered the way you would expect: closing one while others are open forgets it, closing the last one remembers it, and a restored window comes back at the position and size it had. On macOS ⌘Q restores every window; on Windows and Linux the last one standing comes back, as in VS Code.
  • Linux on arm64 — a .deb and an AppImage, from the same APT repositoryEvery release now builds both architectures, and the APT repository serves a binary-arm64 index beside binary-amd64, so apt install platypusgit on a Raspberry Pi or an arm64 cloud desktop gets an arm64 package instead of nothing at all. The install script reads the architecture from dpkg --print-architecture, so the command on the download page is the one it always was and anyone already installed on x86_64 sees no change. The in-app updater knows the difference too — latest.json gained linux-aarch64 entries, so an arm64 install is offered an arm64 update. Both legs build against glibc 2.35, which is what keeps the packages loading on Debian 12.
  • A diff the size ceiling refused can be read anywayThe 5 MB blob ceiling says honestly why a file has no diff — "File too large to diff — 40 MB — over the 5.0 MB limit, so it was not read." The sentence was right and there was nothing to do about it, and leaving the app was the only route past it. That notice now carries a "Diff it anyway" button, on every diff surface, which re-reads that one file against a 64 MB ceiling. It is per file and per view rather than a setting, deliberately: a persisted "always diff huge files" is a footgun you forgot you armed. Two honest edges — over 64 MB the answer is "This is over the largest size the app will diff", and a waived diff stops at 100,000 lines and says "Diff shortened", because a 40 MB CSV is about a million diff lines and laying all of them out is not a favour to anyone.

Fixes

  • Staging no longer untracks — or deletes — a file that changed outside the appThe most serious fix in this release. The app holds a git repository open and libgit2 keeps that repository's index in memory, so it held whatever snapshot this process last saw. Committing re-read the index first; staging, unstaging, discarding and deleting an untracked file did not, and each of them then writes the whole index back or decides from it whether to unlink a file. So with a file git added outside the app — in the built-in terminal, by a pre-commit hook that restages, or from a second window — staging or unstaging reverted it to untracked, and discard or delete removed it from the working tree: a path missing from a stale index reads as untracked, and untracked is the branch that unlinks instead of restoring. Discarding an unrelated file was enough to trigger it, because that writes the index back as well. All four paths now reload the index first, within the same lock they already held, and a six-case reproduction is kept as the regression test.
  • Every diff path is capped, and a huge text file is called too large rather than binaryThe size ceiling was set at exactly one of six diff builders, so the three that feed the commit panel and every commit diff — plus both stash diffs — inherited libgit2's 512 MB default. Clicking a checked-in 80 MB bundle.min.js diffed the whole file, allocated a string per line, shipped the lot across IPC as one JSON payload and then re-measured every row on every scroll event. All six are capped now, so the backend has one policy instead of one capped path and five uncapped ones. libgit2's own answer to an over-size blob is to flag it *binary*, which would have made this a two-line fix that told you your text file was binary; the delta now says why it has no text instead, so "binary" keeps meaning binary and image previews still work off it.
  • The app opens on a drawn dark window instead of a white flashTwo independent causes, both below CSS. Nothing set a background colour, so with no stylesheet loaded yet the window layer and the webview layer both defaulted to white — and CSS cannot reach either before it has loaded. On Windows and Linux the native title bar was additionally stripped after the window had already been shown, so you saw the frame go. The window is now created hidden and revealed on the frontend's first paint, so it appears already drawn, which also makes the decoration strip invisible because it happens while nobody can see the window. A splash screen was considered and rejected: the gap is a few hundred milliseconds, and trading a white flash for a logo flash only makes startup feel longer. A timed fallback shows the window regardless, since a window that never gets shown is a process with no interface.
  • The Store app's taskbar icon is no longer plated in your accent colourA Microsoft Store install showed the app icon on a blue rounded square in the taskbar and in Start. Nothing was wrong with the icon: that is the Windows system icon plate, drawn in your accent colour, which Windows adds whenever a package offers no unplated candidate at the size it wants — and this mark is transparent by design, so it needs none. The package now ships the whole documented size ladder in all three candidate forms, plus the resource index that makes Windows read those filenames as candidates at all. Without that index a package carrying all forty-two assets behaves exactly like one carrying none, which is why the ladder alone was not the fix.
  • A whitespace-only commit message is refused rather than accepted and then droppedUnder commit.cleanup = verbatim nothing is stripped, so a box holding only spaces survived cleanup non-empty, lit the Commit button, and was then reduced to an empty string on the way out — the button and the send path were asking different questions about the same message. They now ask the same one, and the backend refuses a blank message outright, ahead of the hooks so a refusal never runs your pre-commit.

Build & packaging

  • The Store package builds its two architectures in parallelx64 and arm64 were packed back to back in one job, which made the Store bundle the release's long pole — a 17-minute job against 9 to 12 minutes for every other build, with every other channel finished while the release still waited. They are a matrix now, so the bundle lands alongside the rest instead of holding the release open for another seven minutes.
  • CI stops paying for a full cache quota and a full CodeQL run on every changeThe Actions cache had filled GitHub's 10 GB ceiling, so entries were being evicted — and the jobs that lose are the ones that run least often, which is precisely the release builds: a cold Rust build measured 577s against 259s warm. A daily pruner now clears caches belonging to dead branches, closed pull requests, superseded tags and stale generations. Separately, CodeQL moved off default setup so it can skip a language a pull request did not touch; pushes to main still analyse everything, because that is the run whose results describe the branch.

Known limitations

  • Two windows on one repository do not share a lockEach window opens its own handle for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using, and terminal and rebase state stay per window. The trade is that work you start on the same repository from two windows is not serialized by the app; git's own index.lock is the arbiter there, exactly as it is between any two git processes. The index-staleness fix above is what makes that safe for staging.
  • A Store update lands hours after the release, not with itUnchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

PlatypusGit is on the Microsoft Store, which is now the recommended way to install it on Windows: Microsoft re-signs the package, so the SmartScreen warning that greets the .msi is simply not there. From this release the pipeline submits itself — cutting a release builds the bundle and hands it to Partner Center with no one uploading anything. Beside that, two features that were half-built land their other halves: a forge host can hold more than one account, and a custom action can appear in the menu you right-clicked rather than only in the palette.

New features

  • The Microsoft Store listing is live, and it is the recommended Windows channelThe reason to prefer it is narrow and real: Microsoft re-signs an MSIX submitted to the Store, for free, so a Store install is the only Windows channel with no SmartScreen warning — the one user-facing Windows problem the project could not solve on its own. Install it from the Store and updates arrive through the Store like any other app. A Store install deliberately has no update surface of its own: no check, no notification, no release link, because Store policy requires that a Store product be updated only through the Store. Every other channel — .msi, portable zip, Scoop, winget — is unchanged and still checks for updates exactly as before.
  • Releases submit themselves to the StorePreviously the .msixbundle was built by CI and then uploaded to Partner Center by hand, which is the kind of step that quietly stops happening. A new msstore-publish job takes the bundle straight off the published release — the same file anyone can download, rather than an internal build artifact, so what Microsoft certifies is provably what users install — and submits it through the Store submission API. It is skipped for prereleases, and it skips a version the Store already holds, so re-running a release cannot resubmit and be rejected. Certification still runs on Microsoft's side and takes hours, so a Store user sees a new version some time after everyone else.
  • More than one forge account per hostA GitHub or GitLab host used to hold exactly one login, so a work account and a personal account on the same host were not expressible — adding the second would have overwritten the first one's token, and switching between them would have been a lie. Settings now shows one row per account with an Active badge, a switch to change which one is used, and Re-check and Remove per account; the token field hides behind an explicit "Add account" once a host has one. Signing out of the work account leaves the personal one signed in. If you were already signed in, you stay signed in: the existing token keeps the exact storage slot it was written to, so nothing has to be re-entered.
  • Custom actions can live in the file and commit menusThe first half of custom actions shipped a parser, a Settings list and a palette entry — and advertised $FILE, $FILES and $SHA, placeholders that nothing could actually fill, because the only context available was the repository. An action now declares which surfaces it appears on — repository, file, or commit — and the file, multi-file and commit context menus hand it the selection they were opened on, so $FILE gets the path you right-clicked and $FILES gets every path in a multi-select. Actions you already had keep appearing exactly where they did: no surfaces recorded means the palette, which is what they were.

Fixes

  • Error banners no longer show an internal enum nameEvery banner led with its error category as the code spells it, so a fresh machine's first commit read "NoSignature: …", a failed push "Network: …" and a refused merge "Git: …" — one line upstream of the readable sentence written precisely so that would never happen. Banners now lead with written prose or with nothing at all. Two related defects on the same path: a pull that hit conflicts was reported as a *network* error, because git writes "CONFLICT" to stdout while only stderr was being read — it is now identified by inspecting the index instead of any text, which gives the same answer whether you rebase or merge; and multi-line git output, like a rejected push with its four-line hint: paragraph, no longer collapses into one run-on red line.
  • A file with no hunks says so instead of rendering a blank paneTwo everyday changes produce a diff with no hunks at all: an empty added file such as a .gitkeep, and a mode-only change such as chmod +x, which git reports without any line range. The row appeared reading 0 / 0 and the pane beside it was simply empty — on the screen the app launches on. The same emptiness reached the right-click menu, where "Copy file diff as text" would put an empty string on the clipboard and report that it had copied a diff. Both now behave.
  • The credential prompt takes Escape, and a cancel is reported as a cancelDismissing the password dialog is now a real answer rather than a keypress the dialog ignored, and cancelling an authenticated operation says so instead of surfacing as a failure of the operation itself.
  • Cancel closes the last gapsA long-running operation could survive the ways you would actually try to stop it — closing the window, the keyboard route, and a timer that had already been scheduled. All three now cancel.
  • Patches synthesized by PlatypusGit mark a missing final newlineA patch built in-app omitted git's \ No newline at end of file marker, so applying it could silently add a trailing newline the original did not have.
  • The commit panel stays put when a clean tree refreshesThe clean-tree panel was unmounted and remounted by a background refresh, which threw away anything typed in it.
  • Branch names are validated on every path that creates oneTwo remaining branch-creating paths accepted names git would then refuse, failing later and less clearly than they needed to.
  • Two dependency advisories closedBuild-toolchain only — browserslist and esbuild, neither of which ships inside the app — with version floors pinned so they cannot regress.

Known limitations

  • A Store update lands hours after the release, not with itSubmission is automatic now; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the .msi, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.
  • Timestamps are shown in your timezone, not the author'sUnchanged from 0.5.0. Where git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.

The Date column could tell you a commit was made "3w ago" and nothing more, which cannot answer the question you were usually asking it: did this land before or after that one? Every date surface can now show the real timestamp — 2026-08-14 13:42 — and hovering any date gives the full stamp with its zone offset, whichever format you picked. A default install reads exactly as it did before: this is a preference, and "Relative" is still what it starts as.

New features

  • A date format preference, in Settings → AppearanceThree choices: Relative (3w ago, the default), Absolute (2026-08-14 13:42) or Both (2026-08-14 13:42 (3w ago)). It applies everywhere a commit date is written — History, Reflog, Compare and the repository browser — so the log column and the panel beside it can no longer describe one instant in two different ways. The Settings row carries a live sample of the format it is offering rather than making you infer it from the word, and the Date column is sized per format, so picking a longer stamp widens the column instead of clipping it. The absolute form is ISO-shaped and zero-padded rather than locale-formatted on purpose: it sorts, every row occupies the same width, and a developer tool should not make you guess whether 08/14 means August or February.
  • Hovering a date always gives the full timestamp, in every formatZone offset included — 2026-08-14 13:42:07 +02:00 (3w ago). Choosing "Relative" therefore never puts the exact time out of reach, and reading one commit's timestamp never means a trip to Settings. The zone is in there because that is what makes a stamp decisive beyond this window: one copied out of a tooltip can be compared against one pasted from a terminal or read off a CI log. The tooltip sits on the date cell rather than the row, so it does not follow the pointer across the message and sha columns.
  • Commit details shows the timestamp inline, whatever the column says2026-08-14 13:42:07 · 3w ago, unconditionally. The detail panel has the room, and "show me the exact date of this commit" was the half of the request that a preference — any preference — would have answered only for the people who went looking for it. Seconds are there because two commits a few seconds apart otherwise read as the same instant. The Reflog detail joins the same formatter: it used to call toLocaleString(), which read differently from the row directly beside it, and differently again on someone else's machine.

Known limitations

  • Timestamps are shown in your timezone, not the author'sWhere git log prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock. The author's offset is not carried across to the interface — a commit reaches it as unix seconds and nothing else — so matching git here is a change to what the backend sends rather than to how a date is written, and it is separate work. The reading you do get is the one the feature exists for: whether this landed before or after that, asked in the clock you are asking it in. The hover names the zone it used, so no stamp is ambiguous about which clock that was.
  • The Microsoft Store listing is still not liveUnchanged from 0.4.1, which fixed the single certification finding the first submission came back with. Resubmitting is a separate manual step, so until the listing exists, install on Windows with the .msi, Scoop or winget. The packaged form has still not been run on a real Windows machine.

The first Microsoft Store submission was rejected, and the reason is worth stating plainly: an app installed from the Store is not allowed to tell you about updates that come from anywhere else. PlatypusGit did — it checked GitHub a couple of seconds after launch and opened a panel offering the release page. On a Store install that whole surface is now gone: no check, no notification, no link. Every other way of installing is unchanged and still checks exactly as before.

Fixes

  • A Microsoft Store install no longer checks for, or mentions, updatesStore policy requires that a product installed from the Store be updated only through the Store. PlatypusGit already knew not to install an update over a packaged copy of itself — an MSIX is read-only, and Windows refuses to launch a package whose files have been altered — but it still asked GitHub what the newest release was, and still opened the update panel with a "View release" button onto an installer download. Finding out about the update in the app is the part that breaks the rule; nothing has to be installed for it to count. A packaged install now makes no request at all, shows no version chip in the titlebar, opens no panel, and offers no release link. Settings shows the version you are running and one line saying the Store keeps it up to date, rather than controls that would be dead anyway.
  • Every other install still checks for updates exactly as beforeThis is a rule about the Store, not a quiet retreat from update checking. Homebrew, apt, Scoop, the .msi, the AppImage and a hand-built binary all behave identically to 0.4.0 — they check, and then either update themselves or hand you the right command for your package manager. Your "Check for updates" preference and release channel are untouched and still travel in an exported settings file; a Store install simply does not consult them.

Known limitations

  • The Microsoft Store listing is still not liveIt has moved, though. The package was submitted for the first time and came back from certification with the single finding this release fixes; nothing about the package itself — its identity, manifest or capabilities — was questioned. Resubmitting is a separate manual step, so until the listing exists, install on Windows with the .msi, Scoop or winget. The packaged form has still not been run on a real Windows machine.

Checking out a branch that another worktree is standing on used to leave the repository looking like you had staged the whole diff between the two branches — a refusal that arrived after the damage. It now refuses before touching anything, and then offers the thing you actually wanted: move the branch into this folder. The holder steps off the branch onto the same commit it was already on, which never opens its working tree, so uncommitted work over there survives untouched.

New features

  • Take a branch from the worktree holding itA branch can only be checked out in one worktree at a time, and until now hitting that wall left you with two bad options: go open that worktree, or delete it. The refusal now names the worktree and its path and offers a third — move the branch here. Accepting it detaches the holder at the commit it is already standing on, which is a rewrite of one HEAD file and nothing else: no checkout, no index write, its working tree never opened. Modified and untracked files in that worktree survive, which is what makes this safe to offer rather than a destructive action wearing a button. The offer is withheld, rather than shown and then failed, when the holder is locked — an explicit "leave me alone" — or is mid-rebase, merge, cherry-pick, revert, am or bisect, because git tracks those against HEAD and moving it out from under one leaves a half-finished operation nobody can explain. Dirtiness is reported to you, never used to refuse.
  • The offer arrives wherever you check out fromThe top menu, the log view, the branch chip and the command palette all funnel through one action, so the choice appears in all four without any of them knowing about it. If the checkout somehow fails after the holder has stepped off, the holder is put back on its branch — leaving it detached would cost it its branch for a checkout that never happened.

Fixes

  • Checking out a branch held by another worktree no longer mangles the repositoryReported from the field, and worse than a failed operation: the checkout wrote the index and the working tree first and only then tried to move HEAD, where libgit2 correctly refused. HEAD stayed where it was while the files on disk became the other branch's tree — so git status showed the entire diff between the two branches as staged, with the current branch's own files deleted from disk. A refusal presented as data loss. Validation now runs before anything is written, the way git itself dies with already used by worktree at … before touching a file, and the one step that can still fail after the tree has been rewritten rolls back to the commit it started from.
  • A declined checkout no longer leaves your work in a stash you never madeFound on the way into the feature above. A checkout auto-stashes uncommitted changes before it starts and pops them after it succeeds, so every path that ended without a completed checkout used to abandon them in an unexplained stash entry. Declining the offer, choosing to open the other worktree, and a take that is refused on re-validation all now pop your work back. Only the refusal raises a banner; declining is a choice, not a failure.

Build & packaging

  • What the version number promises, written downThis release is 0.4.0 rather than 0.3.2 because of a policy that landed with it. Pre-1.0 the minor is the major: any new user-visible capability, any new distribution channel, and any change to a persisted format or on-disk state is a minor bump, while patches are fixes, performance and packaging repairs with no new capability. The test is the status field on the entry you are reading — if it says "feature", the release is a minor. Everything published up to 0.3.1 is grandfathered and deliberately not rewritten, which matters because the old record is the counter-example rather than the pattern: fifteen of the first twenty-four releases were patch-numbered while their own changelog called them features, and 0.3.1 shipped an entire built-in terminal as a patch. The reasoning, the release runbook and the traps behind it are in docs/dev/releasing.md.

Known limitations

  • The Microsoft Store listing is still not liveUnchanged from 0.2.0 through 0.3.1: the release produces a submittable package, and submitting it is a separate, manual step. Until the listing exists, install on Windows with the .msi, Scoop or winget. The packaged form has also still not been run on a real Windows machine.

A built-in terminal: a real pty in a docked panel, one shell per repository tab, opened in that repository's working directory. vim, less, ssh and an interactive rebase all behave in it, because anything less is a text box that lies about being a terminal.

New features

  • A built-in terminal, opened where your repository isThe editor-standard terminal chord — Ctrl and the backtick key, or ⌘ and backtick — docks a terminal at the bottom of the window, one shell per repository tab, started in that tab's own working directory. It is a real pty rather than a command runner, which is the whole point: vim, less, ssh and git rebase -i all work, and anything less is a text box that lies about being a terminal. The shell is the one your own terminal uses — $SHELL, or PowerShell on Windows — and a Settings field overrides it, because a built-in terminal running a different shell from the one outside is a surprise nobody asked for.
  • A command typed in the pane already updates the appThis needed no code of its own, because the filesystem watcher that shipped in 0.3.0 does it: a git commit or git checkout typed into the terminal moves the graph and the file list with no manual refresh, since the watcher classifies the change against the gitdir and asks for exactly the refresh it implies. Two features that were specified separately turned out to be one.
  • The terminal writes nothing to the logA terminal is where a sudo password gets typed, so the module that carries its bytes contains no logging call at all and a guard test fails the build if one is ever added — the traffic has exactly one destination. The lifecycle logging lives in the handlers instead, which never see the bytes. The configured shell is also held out of a settings export: it is a path to a binary on the machine that wrote it, not a preference that travels.

Known limitations

  • The Microsoft Store listing is still not liveUnchanged from 0.2.0 through 0.3.0: the release produces a submittable package, and submitting it is a separate, manual step. Until the listing exists, install on Windows with the .msi, Scoop or winget. The packaged form has also still not been run on a real Windows machine.

The working copy is live — save a file in your editor, tab back, and the status is already right, with no refresh and no auto-fetch timer involved. The rest of the release is about undoing a mistake and being told what an operation is about to do: ⌘Z undoes the last operation, rebasing the bottom of a stack carries the branches above it and names them before it starts, and the commit panel says which identity it is about to commit as. The command palette also runs commands you define yourself.

New features

  • The working copy is liveSave a file in your editor, tab back, and the status is already right. Until now the app was only correct if you remembered to refresh: the sole self-starting refresh was the auto-fetch timer, which is a network fetch on a minutes-scale interval and does not run at all when auto-fetch is off. One watch follows the active repository, and the value is in what it drops — every path is classified, and only a change to a ref asks for the expensive history refresh, so a file save cannot repaint the log. .lock files are ignored on purpose: they are git STARTING work rather than finishing it, so honouring them would double every event and fire the first one while the index is still being written. Worktree paths are filtered against the repository's own ignore rules.
  • Undo the last operation with ⌘ZThe reason people fear a git GUI is that a misclick is unrecoverable unless they already know the reflog well enough not to need the GUI. ⌘Z (Ctrl+Z) now undoes a commit — including an amend — a checkout, merge, cherry-pick, revert or reset, and ⌘⇧Z redoes it. It moves HEAD between a before and an after snapshot rather than replaying operations backwards, so undoing a commit loses nothing: git keeps the old commits reachable through the reflog. Preconditions are re-read from the backend at the moment you press it rather than trusted from cached state, so if HEAD moved underneath it, it refuses, changes nothing, names the operation and points at the reflog.
  • Rebasing a stack carries the branches above itfeat/a → feat/b → feat/c, each a small reviewable PR on top of the last. Rebasing the bottom silently orphaned everything above it: the branches kept pointing at the old, abandoned commits, and recovering by hand is a chain of manual rebases — exactly where people give up on the GUI. They now follow, and the more valuable half is that you are told first. The confirmation names every branch it will move — "this will also move feat/b and feat/c" — rather than counting them, and says they will need a force-push. It stays silent when nothing points into the range, because a confirmation on every rebase is trained away in a week. A branch whose commit was DROPPED is left alone: there is no honest place to move it, since retargeting to a neighbour would silently change what the branch contains.
  • Your own commands in the command paletteThe one git-adjacent command a team runs fifty times a day, without it having to be something shipped here. Give an action a label and a command line using $FILE, $FILES, $SHA, $BRANCH, $REPO, $LOCAL or $REMOTE, and run it from the palette. A user-supplied command string is deliberately NOT a shell line: under sh -c a branch named main; rm -rf ~ or a path containing $(...) stops being data and becomes code — and branch names and paths come from the repository, which means from anyone who has ever pushed to it. The string is split into arguments once, quotes group, and | > ; && $ * are ordinary characters because nothing interprets them. Placeholders expand into individual arguments, so a value can never introduce a new one. No secret reaches an action: no forge token, no git credential, no askpass. Output is truncated with a marker rather than silently.
  • A repository can commit under an identity of its ownThe commit panel's attribution line said "(signature will come from git config)" — true, and useless to the person with a work address and a personal one who needs to know which this repository is about to use. It now names the identity and which config file it came from, and when the two halves come from different files — user.name from /etc/gitconfig and user.email from ~/.gitconfig is ordinary on a managed machine — it names neither, because naming one would be a confident wrong answer about the other. Saving takes an explicit scope, and a save scoped to one repository is refused rather than quietly downgraded to global. On top of that sits a small list of saved identities with a one-click "Use here". It is a palette rather than an assignment: git already records which identity a repository uses, in that repository's own config where the CLI and every hook read it, so a second store of the same fact would drift the moment anyone ran git config in a terminal. "Which one is active here" is answered by reading the config back and matching on the name and email pair rather than the label, so a repository configured by hand still lights up the entry it corresponds to. Editing or removing an entry therefore does not change a repository already using it — deleting a bookmark does not move the page. The list never travels in a settings export, since every other preference says how the app should behave while this one is a list of someone's email addresses.
  • Commit bodies render as restrained markdownLong bodies with lists, links and code fences read badly as plain text, and every squashed PR body is one. The subset is parsed into a typed syntax tree and rendered as elements, so there is no HTML string anywhere in the path and the dangerouslySetInnerHTML class of bug is gone by construction — rather than pulling in a general-purpose markdown library, which renders arbitrary documents and needs a sanitiser that stays correctly configured forever. Deliberately outside the subset: headings, because a leading # in a commit body is far more likely to be an issue reference; images, so there is no image node a renderer could grow one from; raw HTML; and tables. Links are allow-listed to http, https and mailto and open in your browser rather than navigating, which in a webview would replace the app. #123 renders as a styled token and NOT a link — linking it means guessing which forge and repository the number belongs to, and a link to the wrong issue is worse than none. The raw view is the original text rather than a re-serialisation of the parse.
  • A release channel, so prereleases are opt-inBeside the existing automatic / only-when-I-ask / never choice, a channel: Stable offers published releases only, Include prereleases also offers release candidates. Stable stays on GitHub's own answer to what is current, which excludes prereleases server-side; the prerelease channel reads the full list and takes the semver-highest entry rather than the newest-created, so a patch cut on an older line cannot beat a newer candidate. It adds prereleases to what you are offered rather than restricting you to them, so a stable release still wins whenever it is the newest thing published, and switching back to Stable does not offer a downgrade — the app cannot un-install a version.
  • Pin branches to the top of the listA fifty-branch repository ordered by recency gives no way to say "keep feat/foo on top". Branches now pin from the context menu, per repository, and a pinned branch is the first row wherever branches are listed. A pin outranks the default-branch pin, because that one is the app guessing what belongs on top while a user pin is an instruction — and an instruction that loses to a guess is not a pin. Pinned rows are lifted out of the folder tree and shown at the top under their full names, since a pinned feat/foo sitting inside a collapsed feat folder would be invisible in exactly the case pinning exists for. With nothing pinned the order is unchanged.
  • Drag repository tabs to reorder themTab order was the order things were opened, and nothing moved them afterwards. Tabs now drag to a new position, with Mod+Shift+Left / Right and Move left / right in the tab menu as the keyboard and mouse equivalents. The arrangement survives a restart without any new storage, because the session already writes the tab array in order and rebuilds it in the order it reads back. ⌥1–⌥9 index that same array, so they follow the strip you arranged. The chords decline at either end rather than wrapping — a drag cannot wrap either, and declining lets the chord fall through instead of silently doing nothing.
  • pgit --debug launches attached and streams the logpgit . detaches and sends the child's output nowhere, so the one launch shape that actually has a terminal was exactly the one that threw the log away — and the level filter was pinned high enough to drop every successful call from the webview besides. pgit --debug keeps the process in the foreground and raises the filter, so the whole sequence reaches the terminal you launched from. --help and --version still win, and the credential-helper short-circuit stays ahead of all of it, so a git prompt that literally reads --debug is still answered as a credential rather than treated as a flag. The notice it prints names the already-running case out loud, because a second launch is forwarded to the running app and exits before this code could detect it — which would otherwise be a silent, log-free success.

Fixes

  • A background refresh no longer wipes the error bannerA failing --ff-only pull on a diverged branch could show no error at all. The pull did fail and did set the banner — but the fetch half of that same pull had already moved a remote ref, so the new filesystem watcher's debounced event landed a few hundred milliseconds later, after the operation had finished and the busy guard had stopped suppressing it, and the refresh it triggered opened by clearing the error. What the user saw was a pull that silently did nothing, which is the worst possible reading of a failure. A refresh nobody asked for now preserves the banner. A refresh you asked for still clears it, because "show me where things stand now" is not compatible with a stale error from a previous action.
  • A fresh machine could not commit, and the error said "NoSignature"git refuses to record a commit until user.name and user.email are set, and on a machine with neither the app showed the error type's own spelling — NoSignature — and offered nothing to click. It had no prose written for it, so the same bare word reached the user from merge, cherry-pick, revert, rebase, tag and stash besides, all of which resolve a committer signature; Unborn had the same problem. Separately, a blank user.email was not classified as a missing signature at all and surfaced as the raw string "failed to parse signature", because libgit2 reports a missing name as not-found but a blank one as a generic error. Both paths now ask the config through the same identity validation the writer uses, so what the app saves and what it calls missing cannot drift apart.
  • The worktree list stopped stepping down the pageLock and Unlock differ by about 30px and the buttons were sized to their labels, so Open and Remove sat at a different x on exactly the locked rows and the action column visibly stepped down the list. A 79-character lock reason wrapped to a second line inside a fixed-height badge, giving every locked row its own height, and a centred maximum width capped off exactly the width the absolute paths needed. Now it is one fact per line — name, branch and sha, path, lock — each clipped with an ellipsis and carrying the full text on hover, the badge reduced to the single word locked with the reason beside it, and the three actions in three identical fixed tracks so the column cannot move. The screen itself goes full width.

Build & packaging

  • Twenty dev-only advisories closed, and a test that keeps them closedTwenty of the twenty-two open npm advisories are cleared with dependency overrides. Dependabot could not open a PR for a single one — its security updater only bumps manifest entries and never writes an overrides block — so they sat open indefinitely with security updates enabled and unpaused, where a missing PR read as "handled" when it meant "unfixable automatically". Nothing vulnerable ever shipped: no affected package appears in the production dependencies, so the exposure was a developer machine or a CI runner, never a user install. The block also has a routine way to die, because a dependency PR that regenerates the lockfile drops it with nothing in the diff that looks like a security change — so a test now asserts every override key is still present, and the two advisories that stay open on purpose are written down rather than forgotten.
  • TypeScript 7, git2 0.21, React 19.2The dependency floor moved across both trees in one sweep: TypeScript 5.8 to 7.0, git2 0.20 to 0.21, React 19.1 to 19.2, Vite to 7.3 and the site's Astro to 7.2, plus grouped minor and patch updates across the rest of the crates, packages and CI actions. Dependabot is now configured for every ecosystem in the repository rather than some of them.
  • CI workflows ask for the permissions they needNeither test workflow declared a permissions block, at the root or on any job, so all ten jobs inherited the repository default and left ten open code-scanning alerts. Nothing was exposed, since that default is already read-only — but it is a repository *setting*: invisible in the tree, reversible in one click, and not carried along when a workflow is copied elsewhere, and ten permanently-open alerts bury the next real one. Both workflows now take contents: read, and the three gate jobs that only read another job's result take nothing at all.

Known limitations

  • Undo covers HEAD, not everythingDeliberately not recorded, and the absence is the safe failure: a push, since the remote already has it; a dropped stash; branch create, delete and rename, which move a ref that is not HEAD; and a rebase, which has its own engine and its own retained summary — folding it in without thinking through an interrupted plan would be an undo that lies. ⌘Z undoes the last thing that IS undoable rather than refusing outright.
  • The Microsoft Store listing is still not liveUnchanged from 0.2.0 and 0.2.1: the release produces a submittable package, and submitting it is a separate, manual step. Until the listing exists, install on Windows with the .msi, Scoop or winget. The packaged form has also still not been run on a real Windows machine — the gate added in 0.2.1 proves the package can be BUILT, not that it works once installed.

The Microsoft Store package now builds. 0.2.0 shipped every other channel correctly, but its Store bundle was never produced — three separate Windows-only faults in the packaging step. Nothing in the app itself changed between 0.2.0 and 0.2.1; if you are already on 0.2.0 there is nothing here for you.

Build & packaging

  • Three faults between a built app and a Store packageEach one hid behind the last, and all three were specific to building on Windows. makeappx, the tool that assembles the package, ships with the Windows SDK but is not on the command path, so it could not be found. With that fixed it ran and rejected its own arguments: the release builds under Git Bash, which rewrites anything shaped like a Unix path, so the flag /d arrived as D:/. With that fixed it got as far as reading the manifest and refused it, because naming the large square tile obliges you to name a wide one too — and 0.2.0 named only the square. The large tile is now omitted entirely rather than half-specified; Windows falls back to the medium one.
  • The Store package is now checked on every change to itTwo of those three faults reached a published release, because nothing before the release job could see them — they need a Windows machine, and no test here had one. A gate now builds a real package on Windows whenever the packaging inputs change, and checks what came out: the executable, the pgit entry point, the app identity, the version. It found the third fault in about a minute. It deliberately does not build the whole app, since the faults were in the packaging rather than the binary, which is what keeps it cheap enough to run every time.
  • The apt check stopped failing on a working repositoryThe release gate that installs from apt.platypusgit.com in a clean container failed twice during 0.2.0 against a repository that was serving the right thing minutes later. It waited for the index to answer but not for it to be current, and the hosting behind it does not publish atomically — so it installed the previous version and reported that as a broken repository. It now waits for the version it expects. No apt user was ever affected; the releases just looked broken.

Known limitations

  • The Store listing is still not liveThis release produces a submittable package; submitting it is a separate, manual step. Until the listing exists, install on Windows with the .msi, Scoop or winget as before.
  • The packaged app has still not been run on WindowsSix behaviours specific to the packaged form remain unobserved on a real machine — most importantly whether git can invoke the app as its credential helper from inside a package directory, which if it fails would break authenticated operations quietly rather than loudly. The new gate proves the package can be BUILT, not that it works once installed. Carried forward from 0.2.0 unchanged.

platypusgit is packaged for the Microsoft Store, so Windows users can install it without a SmartScreen warning and let the Store keep it updated. Alongside it: find in diff, image previews, commit-message help that honours your repository conventions, branch folders, fast-forward without checking out, and an SSH key you can generate from the failure that needed it.

New features

  • On the Microsoft Store, as an MSIX packageA fourth Windows channel beside the .msi, the Scoop bucket and winget — the same binary, packaged so the Store signs it and delivers its updates. That removes the SmartScreen warning a new user meets on first download, which until now could only be bought away with a code-signing certificate. A Store install stands its own updater down and says so: the update panel names the Store rather than offering an install that could not work, because the package is read-only and Windows refuses to launch one whose files were changed. pgit still works, through a Windows app execution alias instead of a PATH entry.
  • Find in diff (Mod+F)A find bar on every diff surface, searching the whole file rather than what happens to be on screen. Diffs are windowed, so the browser's own find would only ever have searched a few dozen rows. Matches are highlighted in place and jumping to one scrolls to it precisely. This REPLACES the old "Find in diff" button, which filtered lines away rather than finding them — it could never say where in the file a match was, which is the actual question.
  • Changed images are previewed, not declared binaryOld beside new, each with pixel dimensions and byte size, and the delta of both — on all five surfaces that previously printed the same dead-end sentence. Format is detected from the file's own bytes rather than its extension. SVG is recognised and refused by name, out loud: it is the one format on the list that can carry script, and rendering it would put that inside the app. Images above 4 MiB are skipped without ever being read.
  • Commit messages that follow your repository's conventionscommit.template seeds the box, a ticket prefix is derived from the branch name, a conventional-commit type and scope picker writes the prefix for you, and 72-character subject guidance shows while you type. commit.cleanup is honoured in full, including scissors, and core.commentChar including auto. Comment stripping follows git's actual rule rather than a simplification: a hand-typed #123 fix commits as written, exactly as git commit -m would, while a template-seeded box strips its comments.
  • Fast-forward a branch without checking it outAn action on any local branch row, a "Fast-forward all" button, and a palette entry. main falling behind while you work on a feature branch used to cost a stash, two working-copy rewrites and a checkout back. Refusals are states rather than errors: a diverged branch says so and does not move, already-current reports no change instead of failing, and a branch checked out in another worktree is refused — moving that ref would make every file look deleted in the other checkout.
  • The branch list groups into foldersBranches group into a collapsible tree on /, arbitrarily deep, with single-child chains compressed — a lone feat/foo/bar stays one row rather than three nested ones, because a prefix that groups nothing is part of the name. Folds are remembered per repository. Remote branches group under their remote for free.
  • Open any diff in your own diff toolOn file rows in the commit panel, the repo browser, and every read-only diff surface. It shells out to git difftool, so diff.guitool, diff.tool and difftool.<tool>.cmd are honoured with no configuration here; a Settings field overrides the tool for anyone who has none configured. Console tools like vimdiff keep the terminal they render in. Until now a merge conflict could be handed to your tool and nothing else could.
  • Generate an SSH key from the dialog that needed itA git@ remote failing with "Permission denied (publickey)" used to be answered with a passphrase box — a prompt for a problem a passphrase almost never fixes. The credential dialog now lists the keys on the machine, says whether the host rejected one or there was never one to offer, copies the public half, links to the host's add-key page, and can generate an ed25519 key. If a requested passphrase does not take, the pair is deleted rather than reported as encrypted when it is not.
  • Shallow, blobless and single-branch clone — and a notice saying soAn Advanced section on the Clone dialog: depth, --filter=blob:none, single branch, submodules. The app then tells the truth about what that left behind, with a strip on History, File history, Blame and Compare and one-click git fetch --unshallow. A shallow clone does not fail — History simply has fewer rows and Blame attributes everything old to one commit, which reads as a repository with a strange past rather than one that is only partly here.
  • git notes, and blame.ignoreRevsFileNotes attached to commits are shown, read per selected commit so the paged log pays nothing for a feature most repositories never use. Every refs/notes/* is shown and labelled — hiding one somebody attached is undiscoverable in a GUI. Blame now honours blame.ignoreRevsFile, so a repository-wide reformat stops attributing every line to whoever ran the formatter. Where such a file is configured both toggle states go through git itself, so the comparison is like with like.
  • Follow the system light/dark appearancePair a light theme with a dark one and the window switches with the OS. On a machine that changes at sunset this was the one window that did not. Existing installs keep the theme they had and land on "fixed"; the matching half of the pairing is seeded, so switching to "Follow system" later keeps the theme you were already using. The merge resolver window follows on its own.
  • Export and import all settings, not just a themeA versioned JSON export of every preference, and an import that validates it. Preferences live in browser storage, which is the least durable place they could be — clearing site data or moving machines lost them silently. The exported set is derived from the schema rather than hand-listed, so a preference added later travels by default instead of being forgotten. Import merges onto your current settings, so an older file cannot silently switch update checks back on.
  • Update checks: automatic, manual, or never"Never" makes genuinely no request from any path — the check button is disabled and the update chip is hidden — for a locked-down or offline machine where an accidental click must produce no traffic. "Only when I ask" keeps the button live for people who just want to control the timing. The last-checked timestamp is deliberately per-machine and does not travel in an exported settings file.
  • Settings → Diagnostics: find the log and hand it to someoneThe log path, a Show file button that opens it in your file manager, and Copy last 500 lines. The copy puts the version, the environment line and the path on the clipboard alongside the tail, because 500 lines may not reach back to the startup header — so a pasted report describes itself even when the interesting part scrolled away. Until now the log lived at a per-platform path documented nowhere a user could see.
  • Delete an untracked fileOn single rows and multi-select, behind a confirmation. A file that git has any index entry for is refused, including one at a conflict stage, so a merge in progress cannot be deleted as though it were untracked. Containment is checked against the real filesystem rather than the text of the path, because a symlinked directory makes an innocent-looking relative path point outside the worktree. A batch containing one bad path leaves everything untouched.

Improvements

  • Network operations and rebases say how far along they areClone was the only operation that could answer "how far?" — everything else showed the same indeterminate spinner, so a 300 MB fetch and a fetch stalled on a dead host looked identical for their whole duration. Fetch, pull and push now stream real progress, rebase reports step N of M as it goes, and the bar carries an elapsed clock past three seconds plus a Cancel button on the operations that can actually be cancelled. Tag push and remote-branch delete had no indicator at all; LFS, submodule update and forge checkout were cancellable with no button.
  • A slow refresh names what it is waiting onOpening a repository runs ten backend reads at once, and one boolean described all of them — so a nine-second launch said only "syncing…". The status bar now names the longest-running read, and clicking expands the full list with a clock each. It waits 400 ms before appearing: a refresh runs on every tab switch and almost always finishes inside 100 ms, and a corner of the screen that strobes all day is one nobody reads.
  • F7 leaves a cursor where it lands"Go to next change" moved a highlight and a scroll position and nothing else, so the next arrow key started over at the top of the file and the hint about the keypress appeared at the far edge of the window. The caret now lands on the hunk it jumped to, in all four diff surfaces, and the hint renders at the caret. Arrows and Home/End in the read-only diff panes now move that caret instead of scrolling by a fixed amount — consistent with the commit panel, which has behaved this way for some time.
  • "Copy path" means one thing nowFour of the six Copy path actions were copying a repository-relative path under an absolute label, while two copied an absolute one — the same label meaning two things depending on where you right-clicked. Copy path is now always absolute and Copy relative path is always workdir-relative, beside it. This is a behaviour change: those four actions now copy an absolute path where they previously copied a relative one, and the relative value is one entry away.
  • A hung call leaves a trace instead of a voidCalls into the backend were logged only once they finished, so one that hung and one that was never dispatched wrote the same thing: nothing. A warning is now written while a call is still outstanding after ten seconds — the only line written mid-flight, which turns a missing completion line into evidence rather than an absence.
  • Every launch records what it is running onOne line naming the OS, the kernel, whether this is WSL, and the git the app will actually spawn — written after the path probe, so it names the real one. Opening a repository logs the path going in and the outcome coming out, so three failures that used to produce identical silence now read differently. A repository under /mnt on WSL additionally warns that every file check crosses the VM boundary there: not an error, but an unexplained nine-second launch reads as a broken app.

Fixes

  • Cancelling a network operation could strand git's lock filesA cancelled fetch or push killed git outright, which could leave index.lock or a ref lock behind and make the next operation fail for a reason that had nothing to do with it. The process group is now asked to stop first and only killed if it does not, so git gets the chance to clean up after itself.
  • The folder picker could fail completely silentlyChoosing a repository folder went through a call the app was not watching, invoked in a way that discarded its failure — so on a system without a desktop portal, which is the environment most likely to hit it, the dialog simply never appeared and nothing said why. It now reports the failure. Cancelling still says nothing, as it should.
  • A finished operation could clear another tab's spinnerActivity was written to whichever tab was open rather than to the repository that started the operation, so an operation finishing after a tab switch cleared the wrong tab's indicator and left its own frozen on the parked one forever. Separately, fetch, push and fast-forward cleared their label the moment a password prompt appeared, so the retried operation ran with no spinner and no Cancel button.
  • Reveal on a folder row opened the wrong folder"Reveal in file manager" on a directory selected that folder inside its parent instead of opening it, because the action assumed its target was always a file. "Open in terminal" had the same fault and would open a folder's parent.
  • The Windows installer claimed GitHub published itAdd/Remove Programs listed the publisher as github. The field was never filled in, and the installer builder falls back to the second word of the app identifier — ours begins io.github.… — so every .msi up to and including 0.1.1 shipped that way. It now reads Jonas Aasberg. Upgrading over an older install behaves exactly as before; only the publisher line and one bookkeeping registry key change.

Build & packaging

  • The .msi upgrade identity is pinnedThe value that tells Windows "this installer replaces that install" was derived from the product name, so renaming the app would have quietly stopped upgrades working and left two copies side by side. It is now written down explicitly, at the value it already had, so nothing changes for anyone already on 0.1.x.
  • winget submission is wired upA wizard for the steps that live outside this repository, and a release job that publishes the manifest — self-disabling until the first submission has been made by hand, because the tooling needs an existing manifest as its template. Pinned to the .msi: the release also attaches a portable zip, and winget must never be pointed at that.
  • The Store package is built and checked by CIThe release builds x64 and arm64, combines them into one bundle, and gates on its shape — the executable, the pgit entry point, the app execution alias, and that each package declares the architecture it claims. The Store identity comes from repository variables and the job fails outright if they are missing, rather than attaching a bundle stamped with a development identity that installs fine everywhere and is rejected only at submission.

Known limitations

  • The Store listing is not live yet, and the package is not yet proven on WindowsThis release produces the package; submitting it is a separate, manual step. Six behaviours specific to the packaged form have not been observed on a real Windows machine — most importantly whether git can invoke the app as its credential helper from inside a package directory, which if it fails would break authenticated operations quietly rather than loudly. Everything testable without Windows is covered by the test suite and CI. Stated here rather than discovered later.
  • Markdown commit bodies are still plain textNotes and blame ignore-revs landed from the same issue; rendering markdown in commit bodies needs a dependency whose size and sanitisation are a separate decision, so it is deliberately not here.
  • The startup fan-out still queues behind one lockThe named loading tasks make this legible rather than fixing it: on a slow filesystem the dozen reads a launch makes finish in one cluster instead of spreading out, because reads of the same repository take turns. That is why a repository on a Windows drive under WSL takes so long to open. Tracked separately.

Windows gets the treatment Debian got in 0.1.0: installing is two lines with Scoop, and staying current is scoop update. The app recognises a Scoop install and stands its own updater down, because self-updating one would have left two copies of platypusgit on the machine — and the pgit command comes with it rather than needing a second step.

New features

  • Install on Windows with Scoop, and let Scoop own updatesscoop bucket add platypusgit https://github.com/jonassaa/scoop-platypusgit then scoop install platypusgit, and every later release arrives through scoop update platypusgit. It installs per-user, so nothing asks for elevation and scoop uninstall platypusgit removes every trace — and it shims both platypusgit and pgit onto your PATH itself, so the CLI needs no second step. Scoop installs a portable build rather than running the .msi, which is what makes all of that true; the trade is that it cannot install the WebView2 runtime for you the way the installer can. Windows 11 ships WebView2 and Windows 10 gets it with Edge, so it is almost certainly already there. The .msi is unchanged and remains the route that needs nothing installed first.
  • The update panel tells a Scoop install to run scoop updateWindows was the one platform that could always swap its own binary, and on a Scoop install that was exactly the wrong thing to do: the in-app update runs the per-machine .msi, which does not replace a Scoop install but adds a second one — the new copy in C:\Program Files, Scoop's old one still on PATH and still behind the Start Menu shortcut, and scoop list reporting the old version from then on. Silently two installs, from one click. The app now recognises a Scoop install and offers the scoop update command instead, the same way an apt-managed .deb is told to run apt upgrade. It decides from Scoop's own layout on disk plus the manifest.json Scoop writes beside the binary — never from an environment variable, which is set for anyone who uses Scoop at all and would have told .msi users to update a package Scoop does not have. The .msi install keeps updating itself in place.

Build & packaging

  • A portable Windows build ships with every releasePlatypusGit_x64_portable.zip — the same binary the .msi wraps, plus a pgit.cmd and the licence. It exists so Scoop has something to install that is per-user and cleanly removable, and it is deliberately not offered on the download page as its own route: an unpacked copy with no package manager behind it would be told about new versions and then install them somewhere else. Take the .msi or Scoop.
  • The bucket manifest is generated, and a real install gates the releaseThe manifest is rendered by a script in the app repository rather than hand-edited in the bucket, so what you install from is reviewed in the same place as the code, and the release job pushes it with the same GitHub App and the same "never on a prerelease" gate that the Homebrew cask and the APT repository already use. Two checks stand between a build and a published manifest: the Windows job unpacks the zip it just built and refuses to attach one whose contents are wrong, and a clean Windows runner then does a real scoop install from the pushed manifest and asserts the binary, the pgit shim and the version — so a broken manifest fails the release instead of reaching anyone.

Known limitations

  • Still no winget packageThat one genuinely waits on a code-signing certificate rather than on code: an unsigned installer means a SmartScreen warning and a harder path through winget's review. Scoop never needed one, which is why it is here first. Chocolatey is not planned.
  • x64 only, on Windows as on LinuxOnly a 64-bit Intel build is published. The bucket manifest is already written in the form that takes a second architecture as one more entry rather than a rewrite, so arm64 is additive when the build exists.

Installing on Debian and Ubuntu is one line now, and staying current is apt upgrade — a signed package repository, an installer served from the bytes that were reviewed, and an update panel that knows which kind of .deb you have. Committing runs the hooks it had been silently skipping, a hung clone or fetch finally has a Cancel button, and the app can hand a file to your file manager or a repository to your terminal.

New features

  • One line installs the app on Debian and Ubuntucurl -fsSL https://www.platypusgit.com/install-platypusgit.sh | sh adds a signed APT repository and installs the app, so every later release arrives through sudo apt update && sudo apt upgrade platypusgit. It is safe under curl | sh the same way the pgit installer is: POSIX sh, set -eu, never reads stdin, every choice a flag or an environment variable, and a --dry-run that prints the plan and changes nothing. The served bytes ARE the repository's bytes — a build step copies the reviewed file rather than keeping a second copy — so what you pipe into a shell is what you can read first. It writes a deb822 .sources with an explicit Architectures and a pre-dearmored keyring, so the client needs no gnupg at all, and fetches to a temp file it moves into place, because an interrupted download that left a truncated keyring would break every later apt update. No apt-get, or an architecture other than amd64, prints why and points at the AppImage: a script that advertises an apt install and quietly drops a different package format costs more trust than it saves typing.
  • The update panel tells an apt-managed install to run apt upgradeThere are two kinds of .deb install now and they need different advice — apt upgrade on a sideloaded one reports "already the newest version" while the panel says an update exists, which is the exact dead end this hint was written to remove. The app decides by whether /etc/apt/sources.list.d/platypusgit.sources exists: one path check, on Linux only, no process spawn. A managed install gets the apt command, character-for-character the one on the download page, because two places giving one user two different upgrade commands is worse than either alone. A sideloaded install gets the one-liner, which upgrades now AND moves the install onto the path where apt upgrade works from then on. The AppImage remains the only Linux build that updates itself in-app — true before this release and nowhere on the page until now.
  • Committing runs the commit-side git hooksPushing ran pre-push while committing ran no hooks at all, so a repository with husky, lefthook, pre-commit or commitlint was enforced on push and silently bypassed on commit, with nothing in the UI saying so. pre-commit, prepare-commit-msg, commit-msg and post-commit now run around the commit. A non-zero pre-commit, prepare-commit-msg or commit-msg creates no object and moves no reference, mirroring the signing chain; post-commit's exit code is discarded, because git discards it. commit-msg may rewrite the message, so the panel now reports what was committed rather than what you typed. Hook output renders inline in the commit panel rather than in a toast, which auto-dismisses and cannot hold forty lines of eslint, or a modal, which would block the panel it is asking you to fix — and it is per-repository state, so switching tabs cannot carry one repository's rejected commit into another's panel.
  • A hook can be skipped once, and cannot become "never again"The escape hatch is visible in both places you need it: a non-sticky checkbox before the fact, and "Commit without hooks" on the refusal itself. Pushing gains a confirmed, danger-marked "Push <branch> without hooks" command following the shape force-push already uses. Neither is ever persisted — a "skip once" that quietly becomes "never run hooks again" is a worse version of the bug this fixes. The commit itself stays libgit2's: shelling out to git commit would have run the hooks for free, but it would also have signed through git's own gpg.program, a second signing chain beside the app's one.
  • A clone, fetch, pull or push can be cancelledOne that hung could only be escaped by force-quitting the app. The Clone dialog's Cancel button now stays live while the clone runs and stops it, and the status bar grows a Cancel beside the "Fetching origin…" label that says what is stuck. Cancellation is keyed by scope rather than by an operation id on purpose: the auto-fetch timer stacks fetches behind a stalled one, and those are operations you never started and cannot point at — cancelling a scope reaches the whole pile where an id would leave it. Auto-fetch also skips a tick while a fetch is still running, so a stalled remote can no longer grow a pile of stuck processes. Pressing Cancel reports a cancellation, not a network failure: a killed git's dying stderr says "early EOF" and "the remote end hung up unexpectedly", and routed through the network error you would have been told your connection broke.
  • A cancelled clone cleans up after itselfA killed git clone cannot run its own cleanup, and the leftovers would fail the NEXT attempt with "already exists and is not empty" — a cancel button whose real effect is to poison the destination. The partial destination is removed, and an empty directory you picked yourself is put back. Safe only because the target validation already refused anything but "absent" or "your own empty directory", and only after an explicit kill and reap, so git is provably no longer writing into it. Deliberately not included: a timeout. One short enough to rescue a stalled host is short enough to kill a legitimately slow clone of a large repository over a poor link, and you are the only one who can tell those apart — which is what the button is for.
  • Reveal in Finder or Explorer, and open in terminalTwo context-menu actions on file rows in the Commit panel and the repository browser, beside Copy path, and on the repository tab strip's menu. Per-platform argv is built as pure functions and unit-tested for all three platforms from any host, and spawned only through the one sanctioned spawner. The Windows launchers are pinned to their system directories rather than looked up by name, because CreateProcess searches the current directory first and this app's working directory IS a repository whenever pgit launched it from inside one — a cloned repo shipping its own cmd.exe would otherwise be what runs. A missing Linux terminal falls through an ordered candidate list instead of failing silently.
  • The window title names the active repository and branchWith several repositories open in one window, the title bar now says which one you are in and what is checked out, so the window is identifiable from the OS window list and from a switcher.
  • pgit opens every screen, and answers --versionThe shim reached three of the app's eleven top-level screens — commit/status, log/history, branches — and a bare pgit branch, an easy typo, fell through to path handling instead of being recognised. All eleven resolve now: branch, files/browse/tree, rebase, remote/remotes, pr/prs/pulls, reflog, submodules, worktrees and settings/config, each in the alias test table, the usage text and the README. pgit --version and -V print the version. The deep views — diff, commit diff, compare, file history, blame — stay out deliberately: they need a payload the shell cannot restore from a screen id alone.

Improvements

  • The .deb declares that it needs gitThe backend shells out to real git wherever libgit2 falls short, so a fresh-box install used to succeed and then fail at runtime in exactly the operations that matter most. Depends: git is what makes "one command and it works" survive a container or a minimal cloud image. A git GUI without git is not degraded, it is broken. The package also declares its vcs section, which the index builder wants.
  • An invalid branch name says what is wrongTags have given a clear message on a bad name since 0.0.13; branches passed the name straight to libgit2 and surfaced whatever came back. The ref-name rules are now shared between the two, so creating or renaming a branch with an invalid name is refused the same way an invalid tag name already was — and integration tests prove a rejected name never reaches the repository.
  • The download page opens on the platform you are onThe OS selector never actually sniffed: it returned macOS unless a URL hash said otherwise, so every Windows and Linux visitor landed on Homebrew instructions. It reads the platform now — Apple first, since iPadOS reports "like Mac OS X" and Android reports "Linux" — and resolves it above the panels so the correct one is the only one ever painted, rather than rendering all three and collapsing the page under the reader. With JavaScript off, macOS opens as a fallback. The page itself is rebuilt from a wall of prose into bordered cards in a grid, with the tab row sitting ON the panel it controls, arrow-key support and aria-selected, Gatekeeper and SmartScreen notes folded into disclosures, and exactly one card per platform carrying the accent so "Recommended" means something. The Linux panel leads with the apt one-liner and reframes the AppImage as what it is: the route for non-Debian distributions, and the only Linux build that self-updates.
  • The screenshots are sharp on whatever display you are reading onThe masters were 1x captures laid out at 1040 CSS px, so a 1x screen got a 0.65x downscale of 1px-stroke text and a Retina screen a 1.3x UPSCALE — both destroy glyph edges. The compression was never the cause and raising quality could not have helped: cropping the same region from the PNG master and the shipped q85 WebP at 1:1 gives visually identical output, so the detail was not in the file. One variant per device pixel ratio is emitted and offered in a srcset so each display paints 1:1, with the 1x variant pre-encoded at the layout width using lanczos3 rather than shipping 1600px for the browser to resample — 345KB down to 211KB. A master too small for an honest @2x variant gets a warning instead of an upscale that costs bytes and adds no detail, and the capture step now REJECTS a screenshot that is not 2x, because a capture size is not a detail to leave to whoever is holding the mouse.
  • A comparison table, with every competitor claim citing that vendor's own pagePrice, account, telemetry, platforms and licence for GitKraken, Fork, Sourcetree and TortoiseGit — on the landing page directly under the "why" grid, because that grid makes four claims and the table is the evidence for them on facts you can check rather than adjectives. One JSON file is the source of truth for both the site and the README, and a test parses the README table and fails the build on drift in any cell, the checked-on date or a source link; two tables that disagree are worse than one. A claim about somebody else's product is never more than one click from the vendor page it came from, enforced rather than intended. The section ends by naming where this app is behind rather than hiding it.
  • The README and CONTRIBUTING were rebuilt, and audited against what the code doesThe README leads with the product and moves install to the top; CONTRIBUTING is ordered clone → prerequisites → run → verify, with the timings said out loud, because the first pnpm tauri dev compiles the whole Rust tree for several minutes with no window and no output and a newcomer watching that assumes it has hung. The corrections matter more than the layout: the pgit shim was documented as unsupported on Windows when the .msi installs it; the feature list predated pull requests, multi-repo tabs, submodules, worktrees, LFS, bisect, branch compare, signed tags, log search, the minimap, syntax highlighting, side-by-side diffs, line-level staging and clone; pnpm tauri build was documented bare when it is a hard error without a signing key; and the Linux prerequisite list named a package CI does not install while omitting four that it does, so following it got you a linker error. Both files now have tests pinning the mechanically checkable half — links resolve, every pnpm <name> is a real script — so they cannot quietly drift back.
  • The e2e suite stopped waiting on itselfA reload race owned 70-80% of the suite's wall time. Settling it makes the gate quick enough to run per shard without the run length being the reason not to look.

Build & packaging

  • The Debian package is platypusgit, not platypus-gitproductName was the one place the project spelled itself "PlatypusGit", and that inconsistency was load-bearing: Tauri derives the Debian Package field from productName alone by kebab-casing it, and the internal capital is a word boundary. Lowercase maps straight through, so apt install platypusgit and apt upgrade platypusgit are the real commands rather than an alias. Done in this release because the window was closing — no release had published to apt yet, so the repository has only ever had to know one name; after the first publish every apt-managed install would have needed a Replaces/Conflicts migration instead. The .deb keeps the old name as provides, replaces and conflicts, because both packages own /usr/bin/platypusgit and a sideloaded older .deb would otherwise upgrade into a hard dpkg file conflict; verified in a container, old package replaced cleanly with pgit still working. The macOS app bundle is renamed with it, and the release now FAILS if the Homebrew cask's app stanza does not match productName, rather than shipping a cask that points at an app which no longer exists.
  • Nothing reaches the package repository until a real apt-get install succeedsThe .deb is published to the signed index only after a gate installs it in a clean debian:bookworm container, driving the same installer the download page tells you to pipe into a shell, asserting the control fields, and running pgit --help — which proves the binary loads and every shared library resolved, where a permissions check cannot. A second job then installs from the live host, which the pre-push gate structurally cannot see: DNS, the Pages build, HTTPS, propagation. The .deb comes from the published release rather than a job artifact, so what lands in the pool is provably the bundle you get. The index is a pure function of the pool with no database — the pool is the state, git is the history — so a re-run against an existing tag is a genuine no-op, and a Release file that listed itself in its own checksums (measured on the first run, not theorised) is now refused outright.
  • The three public promises have tests behind themNo telemetry, no account, and no outbound traffic beyond your git remotes, the update check and forge APIs you configured. All three were true by inspection and nothing kept them true — one transitive dependency that "just" reports errors, or one well-meant "help us improve" toggle, and the claim becomes a lie. Two guards, one per tree, because a single test over both would be skipped by exactly the change it polices: no analytics package in package.json or anywhere in the lockfile, no network call or analytics global in shipped frontend source, one direct HTTP client in the backend with ureq confined to its two disclosed call sites, the updater endpoint exactly as disclosed, no permission handing the webview its own client, and every hard-coded hostname allow-listed with a written reason. Every guard was verified to fail on a planted violation before it landed.

Known limitations

  • The apt repository is amd64 onlyThere is no arm64 Linux build yet, so the installer detects the architecture and refuses with an explanation rather than installing a package that cannot run. The AppImage is the route in the meantime, and arm64 is tracked as its own issue. The client side of the smoke gate is pinned to amd64 for the same reason: on an arm64 machine apt verifies the index, fetches the package list and then reports "Unable to locate package", which reads as a broken repository rather than a wrong architecture.
  • In-place .msi upgrades break once, on this release onlyThe MSI UpgradeCode is derived from productName, so the rename gives 0.1.0 a different one and Windows will not treat it as an upgrade of an installed 0.0.17 — it installs alongside. Harmless today because there are no real .msi installs to migrate, which is precisely why the rename happened now; it is written down as something to pin before there are.
  • The installers are still unsigned, and macOS is not notarizedThe app is ad-hoc signed but has no Developer ID, so Gatekeeper quarantines the .dmg — the Homebrew cask strips the flag, and a manual drag needs the xattr line the download page gives you — and Windows shows a SmartScreen warning on the .msi. Unchanged this release, and named in the comparison table rather than left out of it.

A diff you can select and copy — as source, not as a column of line numbers — with Mod+C and a right-click menu that reach past the rows the window happens to be rendering. F7 now centres the change it lands on whatever its size, and the app icon lost the dark box it was sitting in.

New features

  • Diff text can be selected and copiedbody is user-select: none for a native desktop feel and no diff surface had opted back in, so the code you were reading could not be selected at all. Every row's code cell is selectable now, on all four diff surfaces, while the line-number cells and the +/− marker stay unselectable: a copied block pastes as source rather than as text you have to clean up by hand.
  • Copy the whole diff, not only the rows on screenThe diff surfaces are windowed, so rows outside the viewport are not in the document and a mouse drag stops at the edge of what is rendered. Two paths build their text from the row model instead, which has no such ceiling: Mod+C copies the line selection, and a right-click menu offers Copy, Copy N selected lines and Copy file diff as text on every surface. Mod+C still means "copy" — it declines whenever a text selection exists and whenever nothing is selected, which leaves the chord unhandled so the webview's own copy runs.
  • A transparent, full-bleed app iconThe shipped icons were a dark #1c2020 square with the platypus head at about 42% of the canvas, so in the Dock and the taskbar the mark read as a small face inside a box. The plate is gone and the head is cropped to a 5.6% safe margin — about 89% of the canvas, sitting on whatever the OS paints behind it, legible on light and dark alike since the eyes live inside the teal head rather than on the backdrop. The whole bundled set (icns, ico, png, and the Windows Store squares) is regenerated from one transparent master.

Improvements

  • F7 centres the change instead of parking it near the topThe four-row lead-in shipped in 0.0.16 answered "one keypress must mean one thing" with a constant: a two-line change and a forty-line one both landed with their first row four rows down, so the big one ran off the bottom with nothing following it on screen. F7, ⇧F7 and the auto-open now put the middle of a hunk's changed extent — first changed row through last, any context between two runs of changes included — on the middle of the viewport, so context stays on both sides of the change at every size. A change TALLER than the viewport cannot be centred without hiding its own start, so it degrades to the old lead-in above its top row. The target snaps to a row boundary either way, so neither edge of the viewport shows a half-sliced line.

Fixes

  • "Copy file diff as text" printed the @@ range twice on a commit diffIt mapped over every line including libgit2's own hunk header, which came out space-prefixed directly under the real one. The shared builder drops it through the same content test the row model uses, so the line numbering cannot drift from what is on screen either.
  • Dragging a file row across the diff does not smear a selection over the codeOpting text back in defeats a body-level user-select: none, because a class beats an inherited value — and that inherited value was exactly what kept a row drag from selecting everything it passed over. The drag controller marks the body for the drag's duration now, which suppresses the opted-in cells while it lasts.

Known limitations

  • A mouse selection still stops at the edge of the rendered rowsWindowing is what keeps a 10,000-line diff scrolling at all, and a selection cannot extend into rows the document does not hold. Dragging to the bottom of the pane selects what is rendered, not the rest of the file — Mod+C on a line selection, or "Copy file diff as text" from the right-click menu, is the path that reaches the whole thing.
  • A change near either end of a file cannot land centredA hunk within half a viewport of the top or the bottom of the file clamps against that end, which is the price of a scroll range that stays honest. F7 also still scrolls when the next hunk was already on screen: every change landing in the same place is the point of it.

Two detours removed: a diff opens where the change is and F7 carries through the rest of the commit, and interactive rebase takes any commit as its base rather than only a branch. The app's last native dropdown went with them.

New features

  • Interactive rebase onto any commitThe base picker took a branch. It now takes anything a rebase can start from — a diverged branch, a commit on another line of history, a bare hash — and the pick / squash / reword / drop plan is on screen before a single commit is replayed. git rebase -i <newbase>, with the plan first. A commit's own context menu in History offers "Rebase current branch onto this…", which is how a base you can already see is usually chosen.
  • A long range is replayed whole, not truncatedThe commit range behind the plan is fetched against the exact ahead/behind count and its length verified, because the underlying listing stops at 200: a longer range would have come back short, and a plan that leaves commits unreplayed still moves the branch ref. A base that cannot be resolved now says so on the screen itself — reached from a context menu there is no picker open to say it in.
  • A diff opens at the first changeWhole file is the default view, so a diff opened on line 1 of unchanged context and reading one began by scrolling to hunt for the change. The first change is now revealed with the cursor already on it — once the geometry is genuinely final: the rows exist, the viewport has been measured, whole-file mode has its file text, and the row model belongs to the file now shown rather than the one being left.
  • F7 carries into the next fileOn the last hunk of a file F7 was a silent no-op that still claimed the chord, so there was no way to keep going from the keyboard. It now flashes "No more changes — press F7 again for the next file", naming the chord from your live keymap; the next press inside the hint's lifetime opens the next file at its first change and moves the file-list selection with it. ⇧F7 mirrors it, landing on the previous file's last change. Each end of the list flashes and stays put — no wrap-around.
  • The update panel's command can be copiedWhen an update belongs to a package manager the panel shows the line to run — brew upgrade …, sudo apt install … — and that line sat in a bare <code> element under the app-wide user-select: none, so the panel's only actionable content could neither be selected nor copied. You had to retype it character-exact from a popover that closes on the next click outside it. There is a copy button beside it now, and dragging across part of it selects it.

Improvements

  • Every dropdown in the app is an in-page listboxThe last native <select> is gone, and everything it gave for free is re-provided deliberately: arrows, Home/End, PageUp/PageDown, Enter and Space to commit, Tab to commit and move on, Alt+arrow per the ARIA pattern, native-style typeahead (one character cycles, a longer buffer narrows by prefix), combobox / listbox / option roles, focus that never leaves the trigger, and an intrinsic width equal to the widest option. Escape closes the dropdown without closing the dialog around it, and the control can finally be themed to match every other picker in the app.
  • A dropdown no longer drives the list behind itThe keymap's text-input policy is the only thing keeping bare-key chords out of list navigation, and it recognises inputs and text areas — not a native <select>. So ↓ inside the old picker also moved History's commit selection, and a letter also fed the focused pane's speed-search. The new control's focus host is a read-only <input>, which the policy does recognise: protection this control never had.
  • F7 parks the change below the top of the paneHunk navigation scrolled by the smallest move that reveals a row, so walking forward pinned every change to the bottom edge with no following context, and the Diff screen skipped the scroll entirely when the hunk was already on screen — one keypress meant two different things depending on where the last one had left the pane. The hunk now lands four rows below the top every time, degrading to flush with the top in a pane too short to park it there.

Fixes

  • The changed-file list showed the previous commit's files while the next diff loadedThe loading skeleton appeared directly above the previous commit's file rows, with that commit's diff still filling the view pane beside it. Every caller keeps the old diffs while the next fetch is in flight — History debounces ↑/↓ through the log by 100ms — so loading was always true together with a stale, non-empty list. The list blanks now and the view pane gets the same code-line skeleton the repository browser uses, while a refetch of the SAME commit still lands back on the file you were reading.
  • Three measurement bugs under the diff, each invisible on its ownThe viewport height was measured once at mount, but every diff surface renders its scroll container only after the diff arrives — so the read found nothing, never ran again, and the height stayed 0 until you happened to scroll. It also kept a stale height when that container went away. And a programmatic scrollTop write is not a scroll event: measured on WebKitGTK, an assignment left the window of rendered rows describing the old position for seconds, so the row scrolled to stayed unmounted. Every programmatic diff scroll now assigns and publishes the new position in one call.
  • Repeated hints replace each other instead of stackingThe flash helper appended a fresh node per call, so two hints raised in quick succession piled onto the same fixed position. It is single-instance now — which the "press it again" hint, the one guaranteed to be raised twice, made unavoidable.

Known limitations

  • The Wayland dropdown freeze is mitigated, not verified fixedDropping the native <select> removes the surface the reported Linux freeze happens on: WebKitGTK maps one as a GDK popup, GDK's Wayland backend refuses to map a popup that would not be the topmost one, and History kept two mounted at all times while Rebase mounts one per plan row. The freeze itself was never reproduced here — the only Linux lane is xvfb on X11, which cannot emit a Wayland-only warning — so the issue stays open, and a report from a Wayland session is worth more than usual.
  • The new scroll positions were measured on the Linux webviewWhere a diff opens, where F7 parks a hunk, and the hand-off into the next file were all verified there. How they land on macOS, and at a high device-pixel ratio, is unverified.

Build & packaging

  • pgit no longer detaches the app in a dev buildBuilding from source, tauri dev runs the app as its own child with the developer's terminal inherited, which the launch's tty test read as "started from a shell" — so the app re-exec'd itself detached, its parent exited 0, and the Tauri CLI concluded the app had closed and took the vite dev server down with it. It read as tauri dev returning instantly and a window that never painted. Shipped bundles are untouched: pgit . still hands the prompt straight back.

New features

  • Check out a branch from the commit it is sitting onA commit's context menu in History offered to check out the commit — detaching HEAD — and to create a branch from it, but not to check out a branch already sitting there; you had to leave for the branch chip, the Branches screen or the palette. One branch is now an inline entry, several collapse into a submenu, and both sit above the detached-HEAD entry, which stays: on a commit that has a branch, checking the branch out is the safer and far more common intent.
  • The menu tells you where you areThe branch you are already on is listed and disabled rather than hidden. A ref that exists only on the remote offers to check it out as a new local branch and goes through the tracking-branch prompt — it never detaches silently. History's "local branches only" filter is deliberately not consulted: that filter thins out crowded ref pills, and hiding a pill must not remove an action from a menu.
  • pgit arrives with the app on every channel that can install itThe Homebrew cask now links pgit as part of installing the app, so a brew install — or a brew upgrade onto this version — gets the command without anyone finding Settings first. That closes both gaps 0.0.12 named.
  • A one-liner for the two channels that run no install codeThe macOS .dmg executes nothing when you drag the app into place, and the Linux AppImage is not installed at all. Both now have a documented curl -fsSL https://www.platypusgit.com/install-pgit.sh | sh on the download page, with an irm … | iex form for Windows PowerShell. The scripts are served from the repository's own copies by a build step rather than a second checked-in copy, so the bytes you pipe into a shell are the bytes that were reviewed — and a missing source fails the site build instead of publishing a 404 at a URL that tells you to pipe it into a shell.
  • Read it before you run itThe download page leads with which channels already have the command rather than with the command itself. Both scripts open as plain text in a browser, both can be downloaded and inspected first, and both take a dry-run flag.

Improvements

  • The + button sits next to the last tabIt was pinned to the far right of the window, so with two repositories open it stood alone at the other end of a window-wide gap, reading as part of the window rather than as the end of the strip. It now renders inside the scrolling strip, immediately after the last tab, at every tab count — one layout rather than two picked by measuring the strip against itself, which on the Linux webview means measuring without a ResizeObserver.
  • The cost, stated: with enough tabs, the + scrolls offOpen enough repositories to overflow the strip and the button scrolls off to the right along with the tabs it follows — and the same scroll brings it back. ⌘O, the command palette and the Welcome screen all still reach the action.
  • Two smaller things went with itThe button drew its own left border against the preceding tab's right border and rendered a double line; that is a single divider now. And scrolling the active tab into view used to stop at that tab's edge and leave the button half-clipped whenever the last tab was the active one, so the strip now aims at the button in exactly that case.

Fixes

  • A repository could open twice, and the diff pane died with itLaunching pgit <path> on a repository the session had already restored opened it a second time, because the two things that produce that path spelled it differently — libgit2 hands back a trailing slash and one of the two stripped it — so /repo/ matched no tab in an open set holding /repo. Two backend handles then existed for one repository and the app went on referencing the one it had just discarded, so from that moment every commit you clicked answered "unknown repository" with no banner anywhere. One spelling for one path fixes the double open, and a second guard fixes the race that made the discarded handle the live one.
  • Three more leaked git handles, none of them in the reportThe visible one is "New repository…", which opened the repository once to create it and once again to show it, leaking a git handle and its open file descriptors on every single use for as long as the app stayed running.

A performance pass: the app is faster where you feel it, and different nowhere. No new features, no behaviour changes — so what is listed is what you should notice.

Performance

  • Scrolling a long diff no longer re-renders anythingEvery scroll pixel used to rebuild the whole screen the diff sits in, at the rate a trackpad generates events. A scroll now costs nothing at all until the window of rendered rows genuinely has to move, with only the narrow minimap gutter following the viewport frame by frame.
  • Highlighting on the first paintA file you have already opened comes up highlighted instead of showing plain text and then correcting itself, because the token cache is read straight away rather than a render later.
  • The first file in a language opens quickerThe syntax grammars ship precompiled, so a language's patterns are native from the start instead of being translated on first use, and the highlighter warms itself up while the app sits idle after launch — so the first file pays for its own work and not for the machinery.
  • Word-level highlighting is computed once per diffRather than redone every time syntax arrives, a fold expands, or you change the row density.
  • Locking is per repository, not per processEvery git operation in the whole process queued behind a single lock, so a log walk in one tab genuinely blocked a status refresh in another, and requests meant to go out together went out strictly one at a time. Two operations on the same repository still take their turn, as they must.
  • A commit's diff is one pass, not one per fileIts cost grew with the square of the number of files in the commit, because the patch for every file was regenerated once per file. Every surface showing a commit's diff inherits the fix: the panel under History, the commit-diff screen, branch compare, and a stash's contents.
  • A long tail of the same kindA diff of one file no longer walks the entire working tree to find it; History's rows stop re-rendering as a group whenever anything at all changes; the file tree stops re-walking the worktree on every render; the commit panel stops paying a cost that grows with the number of changed files on every keystroke in the message box; the command palette stops rebuilding and re-sorting its whole index while closed; and the titlebar stops re-rendering on every write to the store.

Fixes

  • A long line no longer paints on top of the line beneath itIn the unified diff a line too wide for the pane wrapped while the row holding it kept its fixed height, so the wrapped remainder drew straight over the rows below and two or three source lines composited into a single row's worth of space. It was worst in the Repo Browser, where the diff is squeezed between the file tree and the file-info sidebar. Long lines now run off to the right and the pane scrolls, and a row stretches to the full width of its content so a changed line's colour, its gutter stripe and the focus ring cover the whole line.
  • The Diff screen's Wrap toggle does something at lastIt was broken in both positions: off, it wrapped anyway, because wrapping was never conditional on it; on, it wrapped and still overlapped, because the fixed row height was applied regardless. Row heights have to stay knowable — the windowing, the jump to a given row, the F7 anchors and the minimap all agree with the page without measuring it — so wrapping is real now, a row grows to fit while it is on, and everything that reads row heights switches off together with it. F7 included, which until now jumped to offsets the rendered rows no longer had. The side-by-side view always wrapped correctly and is untouched.
  • Windows: the console flash is goneA release build is a windowed process owning no console, so each time the app ran real git, Windows made a console for it and drew the window that hosts one — and of the twenty places this app starts a process, exactly one set the flag that prevents that. Selecting a commit was the mildest case: staging or discarding a hunk runs git apply, the git-LFS check fires for a feature your repository may not use, every read of bisect progress is another, and auto-fetch runs on a timer, so a console flashed with no action on your part at all. It only happens in an installed build, too: a build from source already owns a console and hands it down to its children, so anyone reproducing it from source concludes there is nothing wrong.
  • One sanctioned way to start a process, with a test behind itAll twenty sites go through it, and the build fails if a twenty-first appears — a helper you have to remember is exactly what nineteen of twenty forgot. Two keep their consoles deliberately, through separately named constructors: git mergetool, and your $VISUAL or $EDITOR. A console editor needs the console it is being given, and silencing it would leave an invisible process holding your file open with nothing able to cancel it.
  • Signature checks skip the subprocess when there is nothing to checkVerifying a commit now asks the git library whether there is a signature at all before running anything. Most commits in most repositories are unsigned — and an unsigned commit displays no badge — so the common case had been paying for a whole subprocess to render nothing. This one is a saving everywhere, not only on Windows.
  • A file whose name contains a glob character was diffed as a globA diff of one path did not switch pattern matching off, so a file named literally *.txt matched every other .txt file beside it: what you were shown was not that file's diff, and the per-hunk and per-line staging that indexes into that diff was indexing into something wider. Rare, entirely legal, and pinned by a test now.
  • Signature verification reported every failure as a bad object idA broken gpg or ssh-keygen installation came back claiming the commit did not exist. It carries git's own message now, and an object that genuinely is unknown is settled before anything is run.
  • One git call could wait forever for an answerIt was the single call in that file setting neither the do-not-prompt environment nor a closed input, so a git that decided to ask a question could hang with nothing in the app able to cancel it.

Known limitations

  • With Wrap off, a long line is no longer visible all at onceThe stated cost of scrolling horizontally instead of overlapping.
  • The diff work was measured on the Linux webview onlyHow the horizontal scrolling and the row backgrounds look on macOS is worth a report.
  • None of the Windows console behaviour has been confirmed on WindowsIt wants a release build on a real machine, and one question is genuinely open: whether gpg and ssh-keygen, which git starts by itself when it checks a signed commit, still flash once their parent has been silenced. The report this came from stays open until somebody looks.

New features

  • The diff reads as a file, not a stack of labelled sectionsThe @@ -12,7 +12,9 @@ banner is gone from every diff surface, and so are the @@ lines that travelled inside each hunk and outlived the bar. A changed line carries a coloured background and nothing else — red for removed, green for added.
  • Stage and Discard are a gutter cluster on the first changed rowDrawn at rest rather than only under the pointer, on the first changed row of each block: a windowed diff cannot wrap a hunk in one element, and a control you can find only by hovering the exact row it sits on is not a control. F7 and ⇧F7 anchor on that same row, which is where the change actually starts.
  • Fold separators instead of per-hunk collapseIn chunked-context mode every gap between blocks says how many lines it is hiding and which range they cover, and expands them in place. Per-hunk collapse is retired: it hid a change while leaving its context on screen, and its chevron would have sat beside a fold separator's chevron meaning the opposite.
  • Hunk staging has real chordsThe old banner's Stage and Discard buttons were mouse-only — no key sequence could reach either. Staging and discarding the hunk you are on are ⌘⇧H and ⌘⇧⌫ now, in both presets. And F7, which did nothing whatsoever in the commit panel or the repo browser, is wired into all four diff surfaces.
  • A minimap down the side of the diff, and you can scrub itEvery diff surface wide enough for one gets a narrow canvas showing the whole file at a glance — where the changes are, how large they are, how they are spread through the file — with a band marking the slice currently on screen. Click to jump, or drag to scrub: pressing inside the band keeps your grab offset, pressing outside centres the row you pointed at, and a drag past either end pins at the limit instead of sliding away with the cursor.
  • The minimap is painted, not measuredIt comes from the diff the app already holds in memory rather than off the page, because a long file is windowed and most of its rows are not there to measure. It hides itself below a container width derived from the diff's own geometry rather than picked by eye, so the narrow commit panel earns one on a wide display and a pane you drag narrow gives it up. It follows the active theme, light modes included, and repaints when you edit the theme you are on.
  • Diff what you have selected in History, from the menu or ⌘DOne commit selected shows that commit's diff; several show the combined diff across the range they span. A single commit's context menu gained a View diff entry — it used to offer Compare with HEAD and nothing else diff-shaped — and ⌘D reads the selection while the commit list has focus, without losing its meaning anywhere else. A selection with gaps in it now says so in the detail pane, since a range diff necessarily covers every commit between the outermost two.
  • Panels grow to the window instead of to a number somebody typedEvery resizable pane had a hard-coded pixel ceiling — a 520px file list, a 640px composer, a 600px tree — so on a large display the panels stayed far smaller than the space allowed. The constraint is expressed the other way round now, as how small the pane on the other side of the handle may get, and that removes the ceiling on its own.
  • Pane sizes follow you between displaysThe size you drag is kept as your preference and clamped to whatever window it is being drawn in, so reopening a 720px panel on a laptop narrows it there without discarding what the external monitor earned. Double-click a handle to reset that pane to its default size — every handle in the app, twelve of them.
  • pgit hands the terminal backpgit . held the prompt for as long as the app stayed open, and Ctrl+C killed the app; it returns immediately now, the way code . does. The detach lives in the binary rather than in the launcher shims, because a symlink cannot detach by construction and four shim-side implementations would have drifted apart. Three cases deliberately stay in the foreground: --help, a launch whose output is not a terminal (so pgit . > file still blocks), and the askpass helper git runs when it needs a credential. Unix for now; Windows is unchanged.

Fixes

  • The merge resolver window leaked the browser's own context menuRight-clicking anywhere in the resolver, over the editable result pane included, offered reload and inspect-element on top of merged work that exists nowhere else yet — and on Linux that menu is a real GTK popup with spell-check and IME submenus hanging off it. The suppression the rest of the app uses is document-scoped, and the resolver is a separate window with its own document, so it had never applied there.

Known limitations

  • The side-by-side view still shows its own @@ separator rowsIt fills no gaps, so those rows have to stay, and turning them into folds needs counts that view does not compute. A follow-up.
  • The new diff area has only been rendered on the Linux webviewThe continuous view, the gutter cluster, the fold separators and the minimap, all at a single device-pixel ratio. How they look on a high-density display, and on macOS in particular, is unverified — a report about it is more useful here than usual.
  • The Linux resolver freeze is not fixedThe context-menu fix was found while investigating it. That one is still unreproduced and its evidence increasingly points at WSLg rather than at this app.

New features

  • Stash the files you pickedA stash used to be all-or-nothing. "Stash this file…" now sits on any row and "Stash 3 files…" on a selection, stashing just those paths from the same buckets Stage and Discard already read. The prompt says up front what you cannot see coming: untracked files in the selection come along, and staged ones are unstaged by the move and come back unstaged when you pop. A selection with nothing modified in it says so, instead of git exiting successfully having stashed nothing while the app said nothing either.
  • Rename a stash entryIt moves the entry to the top of the list — git's stash reflog can only be prepended to, so the prompt states that rather than letting you discover it. The rename is additive first, so a failure anywhere leaves you a duplicate you can drop rather than a gap.
  • Two comparisons that are both the right way round"Show what it changed" runs against the entry's own first parent and folds in the untracked files a -u stash keeps in a third parent that no tree diff can reach. "Compare with working tree" cannot reach them and therefore excludes untracked on both sides, and says so in the header. The one previous way to look inside compared the entry against whatever HEAD is now, and backwards — so it mixed the stashed work with everything that had landed since and drew it as deletions.
  • pgit arrives with the appThe command-line launcher only ever appeared if you found Settings → Command line and clicked Install — and on macOS that usually failed, because /usr/local/bin needs root. The Linux .deb now ships /usr/bin/pgit and the Windows .msi installs a pgit command and puts its directory on your PATH, both as ordinary package contents, so removing the app removes them too.
  • The in-app install got better anywayOn macOS it walks an ordered list of directories and takes the first one it can actually write, so root is an edge case rather than the normal path; if that directory is not somewhere your shell looks, it tells you, with the line that fixes it, rather than reporting a successful install of something you cannot run. On Windows it is real — a per-user directory plus a per-user PATH entry.
  • A pgit that is not ours is named and left aloneOne your package manager installed is never overwritten and never offered for overwrite: Settings names where it came from and shows no button at all.

Fixes

  • Every backend failure reached the log file as [object Object]An error crossing from Rust is a plain kind-and-message object rather than a JavaScript Error, so stringifying it threw the reason away, and a Linux bug report arrived as a burst of identical opaque ERROR lines with nothing in them to diagnose. A log line now leads with the error kind, which is the half you grep for, while a banner still never shows an enum's spelling.
  • Two failures nobody could seeA failed Apply in the merge resolver showed the user [object Object], and the chooser's "Keep our version" / "Take theirs" — the resolver's fallback for a binary or deleted-side conflict — reported nothing at all, so a failure looked exactly like a button that does nothing.
  • Unhandled render errors reach the log fileWith their component stack. They existed only in a devtools console that a reporting user never opens.
  • Clicking an ordinary submodule row cost three errorsA gitlink names a commit in the submodule's own object database and all three file readers looked for it in the wrong one. "There is no text at this path" is an answer now rather than a failure — for a submodule, for a directory, and for the side a diff legitimately does not have, since an added file has no old version and a deleted one has no new version.
  • A swallowed error took the credential prompt with itAn error the log formatter itself could not read used to replace the failure it was reporting, taking with it the one detail that raises a credential prompt: a fetch against a private remote then simply failed instead of asking for a password.

Known limitations

  • Hunk-level stash is deliberately not hereThe composition it needs rewrites and restores your index around a subprocess, and an interruption in that window would leave the selection as your index with no other copy of the staged work anywhere. There is no half-built affordance for it either.
  • Two channels still install pgit from SettingsThe Homebrew cask does not carry the command yet, and a .dmg drag-install runs no code while an AppImage is never installed. Settings → Command line, or scripts/install-pgit.sh from the repository, remains the route for those.
  • The Linux freeze that report opened with is not fixedIt was never reproduced, the evidence points at a GTK popup on Wayland, and it stays open.

New features

  • Branch compare — what is on that branch and not on this oneRight-click any branch, local or remote, and compare it with the current branch, with the working tree, or with another branch you marked earlier; the command palette has the same entries. You get the ahead/behind counts and the merge base, both commit lists, and the combined file diff rendered by the same pipeline every other diff surface uses — so word highlighting, syntax and F7 come along. Either side can be changed or swapped without leaving the view.
  • The working tree is a right-hand side onlyIt is not a commit, so there is nothing to count or walk, and the summary and the two commit lists are absent rather than shown as zero. Untracked files count as additions — hiding a file you just wrote is the one case the view exists for — and if there are too many of them the untracked side is dropped whole and says how many, instead of truncating quietly. The diff also says out loud that it is a tree-against-tree comparison, so files that exist only on the base side reading as deletions is stated rather than discovered.
  • Signed tagsAnnotated tags can be signed with GPG or SSH, through the same key resolution commit signing has used since 0.0.8: tag.gpgsign sets the default and any tag can override it. Creating a tag is one dialog now — name, annotation, sign — replacing three separate single-value prompts. The sign box has a third state meaning "follow the git config", because a plain unchecked box would claim a tag is unsigned in a repository that signs everything; and signing needs an annotation, since a lightweight tag is a reference with no object to sign. A signing failure creates no tag at all.
  • A tag verdict worth trustingSigned tags are marked in the Branches list, and the selected tag carries a graded badge: Signed, Bad signature, or "Signed, key unavailable" for a signature from a key outside your allowedSignersFile — which is not the same thing as verified.
  • Branch lists in an order worth readingEvery list of branches — the titlebar picker, the Branches screen, ⌘P — showed git's own ref order, which is alphabetical: chore/bump-deps above main, and a branch you touched five minutes ago below one abandoned last year. The default branch now pins to the top and everything else falls in recency order, newest commit first; remote branches get the same treatment within their own section. The default is git's own answer, origin/HEAD, falling back to whichever of main, master or trunk exists — deliberately not init.defaultBranch, which describes branches that do not exist yet.

Fixes

  • Annotated tags were unreachable from the commit menu and the paletteBoth paths passed no annotation, so creating a tag from either could only ever produce a lightweight one.
  • "Compare with current" could silently no-opThe remote-branch menu resolved both tips itself and did nothing at all when either was missing. It is the ref-named compare now, which cannot.
  • The branch picker's keyboard cursor ignored the filterIt survived a change of query instead of following the filtered list. Filtering still beats pinning, so a query that excludes the default branch does not resurrect it, and the cursor now rests on the current branch — the one row where a stray Enter does nothing at all.
  • "Create tag here" is hidden in an empty repositoryRather than offered and then refused.

New features

  • Several repositories open at onceRepositories are tabs now, on their own strip below the titlebar: open as many as you like, each with its own screen, its own dirty and conflict badges, and a right-click menu to close one, the others, or all. Colliding names are disambiguated by their parent directory. ⌘E opens a switcher, ⌥1–⌥9 jump straight to a tab, Ctrl+Tab cycles, ⌘W closes. Your open set is restored on the next launch, and lazily — five persisted repositories cost one open, not five.
  • Pull requests and merge requests, GitHub and GitLabA new Pulls screen lists the open requests for whichever forge your remote points at — number, title, author, source → target, draft and fork markers — with the CI/checks summary for the selected one. Open it in the browser, check it out locally (forks included, via the ref the forge publishes on the base repository), or create one from the current branch with a title, body, target and draft flag. Self-hosted GitHub Enterprise and GitLab work the same way. The API token is per host, entered in Settings → Integrations, and handed to your own git credential helper under a key that cannot collide with the credential you push with.
  • Submodules get a screenInit, update (recursively if you want), sync URLs, or open one as its own repository — and they are told apart from merely embedded repositories in the file tree.
  • Linked worktrees get a screenAdd on a new or existing branch, lock with a reason, remove, prune. Removing one that holds uncommitted work asks a second time before it will force.
  • git-LFS on the Remote screenFetch objects, pull objects, checkout — and an LFS pointer now renders as the object it stands for rather than as a two-line text diff.
  • Bisect in the operation barGood / Bad / Skip / Reset and git's own "N revisions left, ~M steps" estimate — including a bisect you started in a terminal, since git's files are the only record either of you reads.
  • Drag and drop where it earns its placeDrag files between Changes and Staged (both directions, tree or flat view); drag a ref or commit onto another in the graph to merge, rebase or cherry-pick, each behind a confirmation naming what it will do; drag rebase steps to reorder them. Illegal drops are refused with the reason on the cursor rather than silently ignored. Every gesture has a keyboard equivalent — reordering gained Mod+Shift+↑/↓, which it never had.
  • Space stages the focused diff lineThe diff pane gained a line cursor beside the existing F7 hunk cursor: arrow to a changed line, Space stages or unstages it — the same rule the checkbox follows, and switched off in exactly the cases the mouse is.
  • A HEAD indicator you can actually configureThe old four-value list had to name every combination; it is now six independent marks — edge bar, row tint, outline, HEAD badge, bold subject, graph ring — at subtle, strong or intense, with a live preview in Settings built from the real History row so it cannot drift from what you get. Existing settings are migrated to the nearest equivalent.

Improvements

  • Whole-file diffs, by default, on every diff surfaceA change is easier to judge with the rest of the file around it, so the whole file is what you see — while each change block keeps its own Stage and Discard, so nothing about staging gets coarser. Chunked context remains a setting.
  • Inline vs. split is a real persisted preferenceInstead of resetting on every navigation. The changed-word tint is stronger and calibrated per theme mode.
  • Highlighting moved off the main threadSyntax tokenizing runs in a worker and returns packed token data, so clicking quickly between files no longer janks on the file you just left, and a commit warms its other files in the background. If the worker cannot start, it falls back to the old path rather than losing colour.

Fixes

  • Tag pushes and remote-branch deletes ran without credentialsAgainst a private remote they simply failed with git's stderr and no way to answer. Both now prompt and retry like every other network operation, and neither can any longer be talked into running a program named by a crafted branch or remote name.
  • Every repository you opened stayed open behind your backFor the life of the process, leaking its handles. Closing a tab now closes it for real.
  • On Linux the bottom of a long diff could render blank
  • Dragging a rebase step reordered plans that could not be reorderedThe buttons correctly said so; the drag did not listen.
  • The Files screen's Unstage drop target did nothing at all
  • Checking out a pull request misread every existing branch as absentSo a name collision surfaced as git's own failure instead of a question.
  • History's "Mine" scope was a guess at your emailIt filtered nothing on your own repositories and someone else's commits on everyone else's. It is gone; the real author filter is in advanced search.

New features

  • Syntax highlighting on every code surfaceThe unified diff, split view, the inline commit diff, blame, the file preview, the repo browser's diff pane and all three panes of the merge resolver. Word-level highlighting inside a changed line composes with it rather than fighting it, and each theme carries its own syntax palette, so light themes are calibrated for a light canvas and switching mode never re-tokenizes.
  • Conflicts are a state the app announces, not a tab you visitWhenever a merge, rebase or cherry-pick is in progress, a bar under the titlebar names the operation and branch, counts what is left, shows the rebase step, and offers one verb — Resolve conflicts, then Finalize or Continue — plus a confirmed Abort. The old Conflicts screen is gone; the resolver window gained a conflicted-file sidebar and now asks before you leave a file with unapplied work.
  • Interactive rebase understands merge commitsA merge in range used to fail partway through and leave the branch half-rewritten. Now the plan is validated before the repository is touched, and merge rows are badged with a warning strip saying what will happen. Flatten (the default, and git's own) drops the merge and replays its side branch linearly, or keeps it as one ordinary commit; Preserve recreates the merges — the equivalent of git rebase --rebase-merges — and states its limitations up front.
  • A rebase survives quitting the appThe replay runs on a detached HEAD and the branch moves exactly once, on completion, with the operation mirrored to disk and ORIG_HEAD re-asserted at every step — so Continue and Abort still work after a restart, and git reset --hard ORIG_HEAD remains a real escape hatch.
  • Squash and fixup run from History, in placeWith a prefilled, editable message built from every commit being squashed; no detour through the plan screen. The rebase plan reorders by drag.
  • History scope that means somethingAll / Mine walk every branch, This branch walks HEAD, replacing a client-side approximation.
  • UI zoom on ⌘= / ⌘- / ⌘0Persisted across launches.
  • A customizable "you are here" HEAD indicatorEdge bar, row highlight, both, or graph marker only.

Improvements

  • Large diffs stay responsiveDiff rows are windowed, so opening a thousand-line file mounts a screenful instead of the whole thing. F7 hunk navigation scrolls by computed offset, so it still reaches a hunk that is not mounted yet.
  • Entering a screen focuses its main paneSo the first keystroke lands where you are looking. The app now opens on History.

Fixes

  • A .deb install would have been handed an AppImage as its update payloadOnce Linux self-update opens up. The update panel now explains itself to .deb users instead of dead-ending.
  • Continue and abort work for a rebase git owns on diskWhich previously abandoned queued steps or left you detached mid-rebase.
  • A branch tip was truncated to seven charactersSo the HEAD marker never drew and the ancestry filter silently matched nothing.
  • Squashing two commits could produce threeRebase operations read the displayed log rather than HEAD's ancestry.
  • The split view's two columns drifted apartOn any hunk mixing removals and additions.
  • Re-selecting the current screen stranded keyboard focus
  • The shell no longer side-scrolls

A large release. The fixes below come from a full review of everything since 0.0.7.

New features

  • In-app updatesWindows .msi and Linux .AppImage installs now download, verify and install updates in place, then relaunch. macOS and .deb are notify-only: they point at the release or brew upgrade rather than stepping on a package manager's bookkeeping.
  • Clone and create repositoriesClone from a URL with live progress, or initialise a new repository, from the Welcome screen, the command palette, or ⌘⇧O / ⌘⇧N.
  • Authenticated remotesFetch, pull, push and clone against private remotes. The first attempt stays prompt-less so an existing credential helper or SSH agent simply answers; only if that fails are you asked, and the operation is retried. "Remember" hands the credential to your own git credential helper, and only once it has actually worked.
  • Line-level stagingClick or shift-click individual lines in a hunk and stage, unstage or discard just those. Plus word-level highlighting within a changed line, tri-state staging checkboxes on files and folders in the tree, and an ignore-whitespace toggle on every diff surface.
  • Commit signingSign commits with GPG or SSH and see a verification badge on signed commits. The commit box is now a single field (subject and body, the shape git stores), Amend loads the previous message, and you can override the author or add Co-Authored-By trailers.
  • History upgradesAn inline commit diff that opens in place without switching screens, multi-select for a combined diff / squash / cherry-pick, and content search (content: / contains:) alongside author, path, date and SHA filters.
  • Editable branch trackingSet or change a branch's upstream from the Branches inspector or its context menu; a first push of an untracked branch establishes tracking instead of leaving it dangling.

Improvements

  • Rewritten commit graphProper lane colouring, crossing edges, a HEAD marker and accessible labelling. History pages in as you scroll, and both the log and the file tree are virtualized, so large repositories stay responsive.
  • PolishLight themes are properly calibrated (diff colours, graph lanes, pills and shadows no longer keep a dark calibration over a light canvas), per-file-type icons throughout, in-app dialogs with real danger styling and type-the-name confirmation for destructive actions, bundled Inter + JetBrains Mono, loading skeletons, and find-in-tree with ⌘⇧F.

Fixes

  • Repositories on a Windows drive under WSL (/mnt/c/…) openThey tripped git's dubious-ownership check and refused outright; you now get an explanation and a one-click way to trust the path.
  • Discarding a conflicted file deleted it instead of restoring the conflict
  • Pull with auto-stash could strand your uncommitted workIn a stash it never popped or mentioned.
  • Staging part of a file could then stage the wrong linesBecause the diff pane kept showing the pre-stage version.
  • Whole branch lanes could vanish from the paginated log
  • Staging a hunk or lines of a brand-new file failed outright
  • The commit summary counted unstaged edits toward the commit
  • Discarding an untracked file silently did nothing
  • UI density skipped some row surfaces
  • A signed commit with an expired key read as unsigned
  • Embedded repositories are detected as data, not guessed from the pathAnd they stay out of batch operations that would write an unresolvable gitlink.

New features

  • Rider-style merge conflict resolverA dedicated resolver window with a three-pane diff3 layout (ours · editable result · theirs). Accept ours / theirs / both per conflict from the gutter or the keyboard — F7 / ⇧F7 to navigate, ⌘1/2/3 to pick, ⌘↵ to apply and auto-advance through conflicted files. Manual edits and CRLF are preserved, with a chooser for binary and deleted-side conflicts. Open it from the Conflict screen.
  • App logo on the Welcome screen and titlebarWith themeable logo colors — every built-in theme carries its own logo palette, and the theme editor exposes the head and bill fills.
  • Branded macOS installerThe .dmg now opens to a classic drag-to-Applications layout with artwork styled to match the site.

Fixes

  • The branch/ref picker scrolls within the popoverInstead of overflowing the viewport.

New features

  • Keyboard navigationA full keymap system with a Rider-style default (Classic preset available), type-to-jump speed-search, commit chords, F7 / ⇧F7 hunk navigation, spatial Alt+Arrow pane focus, and a ? cheat sheet.
  • pgit command-line launcherOpen a repo from the terminal with pgit [subcommand] [path]; it forwards into a running instance, and the shim is installable from Settings.
  • Ref-scoped historyBrowse the commit log of any branch, tag or revspec and cherry-pick from unmerged refs, via the History ref selector.
  • Command palette upgradesAn actions catalog, frecency ranking, drill-in steps, and type-filter chips (⌘P / Ctrl+P).
  • Multi-file selectionSelect several files in the commit panel or repo browser and stage / unstage / discard them from the context menu.
  • SettingsConfigurable diff context lines and UI density; non-functional toggles removed.

Fixes

  • Interactive-rebase conflict resume now completesAnd aborting no longer discards a resolved commit.
  • The palette's type chips run the highlighted row
  • Palette Pull honours your pull-mode setting and tracking branch
  • The commit shortcut no longer double-commits on key-repeat
  • History selection resets when a filter shrinks the list

Build & packaging

  • Windows .msi now buildsAdded an .ico to the icon set so the Windows bundler stops failing.
  • Multi-platform release assetsmacOS universal .dmg, Windows x64 .msi, Linux amd64 .deb and .AppImage.

Build & packaging

  • First release built for all three platforms via CImacOS .dmg, Windows .msi, Linux .deb and .AppImage.
  • Validates the Windows and Linux build jobsAssets attach automatically once the release workflow completes.

New features

  • Recent commit messagesA "Recent" button in the commit panel refills the message from your recent commit subjects and bodies — newest-first, de-duplicated, skipping merges.
  • Sign-off (-s) toggleAppends a Signed-off-by trailer from your committer identity with full git commit -s semantics: idempotent, correct blank-line separation, git-accurate trailer-key rule. Applied on normal and amend commits; the preference persists and stays in sync with Settings.
  • Browse the repo tree at any revisionType a revspec (SHA, branch, tag, HEAD~2, …) or quick-pick a branch or tag to list the full file tree and view file contents as they were then, with syntax highlighting and binary-blob handling.
  • Commit and log search in HistoryFilter by message, author, SHA prefix, date range and path, with free-text qualifiers (author: / path: / sha: / since: / until: / message:). Backend-filtered over a revwalk; results render through the commit graph.

New features

  • Command palette / fuzzy finderOpen with ⌘P / Ctrl+P to jump to any branch, file, recent commit, or app command from one overlay.
  • Fuzzy matching that ranks the right thingsConsecutive runs, word boundaries and camelCase, with keyboard-first navigation (↑/↓, Enter), match highlighting and a trapped focus ring.
  • Selecting a result acts on itBranches check out, files open in the diff view, commits show their diff, commands switch screens.

First public release of platypusgit — a dev-first git desktop app built with Tauri 2 + React.

What shipped

  • StagingStage / unstage / discard whole files and individual hunks; commit with amend and author override.
  • Diff & viewingWorktree / index / HEAD diffs, commit-to-commit diffs, line-by-line blame, and a repo file browser at HEAD.
  • Branches & tagsList / create / checkout / rename / delete branches; lightweight and annotated tags; push and delete tags.
  • HistoryCommit graph layout, per-file history, reflog viewer, detached-HEAD checkout.
  • History manipulationReset (soft / mixed / hard), cherry-pick, revert.
  • StashSave / apply / pop / drop, and stash to a new branch.
  • Conflict resolution3-way sides, accept ours / theirs, external mergetool, continue / abort.
  • Interactive rebasePick / reword / edit / squash / fixup / drop, continue / abort, and a rebase base picker.
  • Remotes & networkAdd / remove / rename / prune remotes, fetch / pull / push (with-lease and force), merge branches.
  • App shellCentralized branch UI — titlebar branch chip and popover picker — a native window titlebar with platform-aware window controls, and light / dark themes.

Build & packaging

  • Universal macOS .dmg build published via CI