No description
Find a file
Marvin Mees 6abdad8405
All checks were successful
CD / release (push) Successful in 11m24s
test: cover the fetch, split-view and diff-panel resize/collapse fixes
Four regression tests against a real, live-bound view (not just the
view models, which didn't reproduce any of these — the crashes only
show up once Avalonia's own binding and control state is in play):

- Fetching_while_a_commit_is_selected_does_not_crash_the_bound_view
- Switching_to_split_view_does_not_crash_a_diff_already_open
- Dragging_the_diff_splitter_down_to_the_floor_and_back_up_recovers_the_panel
- Collapsing_the_diff_panel_shrinks_it_to_its_header_and_expanding_restores_it

Each was confirmed to fail against the pre-fix code before being
checked in passing.
2026-08-17 10:31:59 +02:00
.forgejo/workflows Build, package and release all five platforms 2026-08-16 09:38:17 +02:00
build/Build Clear stale archives before publishing 2026-08-16 10:00:57 +02:00
docs/images Document the round and refresh the screenshots 2026-08-16 16:47:17 +02:00
src fix: rename the start page's tab label from muit to Home 2026-08-17 10:31:47 +02:00
tests test: cover the fetch, split-view and diff-panel resize/collapse fixes 2026-08-17 10:31:59 +02:00
.editorconfig Add the Avalonia app shell and start page 2026-08-15 19:21:05 +02:00
.gitignore Scaffold solution, projects and build configuration 2026-08-15 18:36:38 +02:00
Directory.Build.props Scaffold solution, projects and build configuration 2026-08-15 18:36:38 +02:00
Directory.Packages.props Add the diff viewer and split the layout 2026-08-16 16:21:14 +02:00
global.json Scaffold solution, projects and build configuration 2026-08-15 18:36:38 +02:00
muit.slnx Build, package and release all five platforms 2026-08-16 09:38:17 +02:00
PLAN.md Added Diff 2026-08-16 16:55:48 +02:00
README.md Document the round and refresh the screenshots 2026-08-16 16:47:17 +02:00

muit

A local git manager for many repositories at once.

An alternative to GitKraken or Fork, built to stay instant on histories the size of the Linux kernel.

CI .NET Avalonia Platforms Tests First paint

muit showing the git/git history


Contents


What it does

muit keeps every repository you work on in one window.

Settings

Point muit at the directories you keep code in. It scans them for repositories and lists what it finds on the start page — stopping at each repository, so checkouts vendored inside one are never listed. Light and dark follow the operating system unless you say otherwise.

Settings

Start page

Create a repository, clone one, open one from disk, or go straight to one of up to five pins. Everything you have opened recently is listed beside them, and anything the scan found but you have not opened yet is listed below.

The start page

Repository view

Three panels above a patch, each loading on its own so a slow one never holds up its neighbours:

  • Branches — local branches, remote branches grouped by remote, and tags, in collapsible groups with a filter and ahead/behind counts. HEAD is marked with an accent bar. Double-click a branch to check it out; right-click for the rest. Checking out a tag asks first, because it detaches HEAD.
  • History — the commit graph, drawn directly rather than templated per row, so the cost per frame follows the height of your window and not the length of the history. Navigable by mouse or keyboard.
  • Changes — staged and unstaged files, with the commit box beneath them. A file can appear on both sides at once, because staged edits plus further unstaged edits to the same path is an everyday situation and collapsing it to one row would hide work. Staging is per file.

Open repositories are tabs in the header. The selected tab carries a sub-header with the current branch and how it stands against its upstream. Every divider is draggable.

The same view in light mode

Diffs

Select a commit and the right-hand panel becomes its details — author, committer, message, clickable parents and the files it touched. Click a file, or a changed file in the working tree, and the patch fills the panel across the bottom: unified or side by side, syntax-highlighted, with the line numbers each side of the change carries.

Side by side pads the shorter of each pair of runs, so a line always sits opposite its counterpart rather than drifting out of step with it.

A commit and its patch

Working with remotes

Fetch, pull, push, merge and stash, with progress read from git's own output. A pull that stops with conflicts is not treated as a failure — it did exactly what it should and found work that needs deciding — so it gets a band with the way out rather than an error dialog.

A merge stopped with conflicts

Resolving conflicts

The band leads to a three-way merge editor: ours, base and theirs across the top, scrolling as one, and the result below. Every disputed run takes ours, theirs, both in either order, or neither, and the result is rebuilt as you decide. It is editable, and what is in it is what gets written.

The three sides come from the index's own stages rather than from the markers in the file, so the common ancestor is always there — git does not write it into the working tree unless you have asked it to. Binary files and paths deleted on one side get a choice of side instead, because there are no lines to merge.

The three-way merge editor

Nothing blocks on git

Panels show a skeleton the moment a repository opens and fill in behind it.

Loading skeletons


Install

Builds are self-contained: no .NET runtime needed. git itself must be on your PATH — muit drives your git, it does not replace it.

Download the archive for your platform from the releases page.

Platform File
Windows x64 muit-win-x64-<version>.zip
Windows arm64 muit-win-arm64-<version>.zip
Linux x64 muit-linux-x64-<version>.tar.gz
Linux arm64 muit-linux-arm64-<version>.tar.gz
macOS Apple silicon muit-osx-arm64-<version>.tar.gz

Linux

tar -xzf muit-linux-x64-<version>.tar.gz
./muit

Windows

Unzip and run muit.exe.

macOS

The bundle is unsigned and un-notarized. Signing needs Apple's toolchain and a paid developer certificate, neither of which exists on the Linux runner these are built on. macOS will refuse to open it and report that the application is damaged — which is not what is wrong with it. Clear the quarantine flag:

tar -xzf muit-osx-arm64-<version>.tar.gz
xattr -d com.apple.quarantine muit.app
open muit.app

Where muit keeps its data

One SQLite file, in the platform's configuration directory:

Platform Path
Windows %APPDATA%\muit\muit.db
Linux ~/.config/muit/muit.db
macOS ~/Library/Application Support/muit/muit.db

It holds settings, the repository registry, pins, and a cache of walked history. Deleting it loses your pins and costs one slow repository open; nothing else.


Performance

The requirement muit exists to satisfy: stay instant on torvalds/linux (1,465,141 commits) and git/git (85,304). Measured through the real load path on full clones, not synthetic fixtures.

Repository Open First paint Full walk Memory
git/git first ever 326 ms 1.4 s —
git/git typical 2 ms 0.5 s 156 MB
torvalds/linux first ever 6.6 s 16.3 s —
torvalds/linux first with a commit-graph 850 ms 10.5 s 859 MB
torvalds/linux typical 2 ms 8.2 s 859 MB

Four things get it there.

A commit-graph is a precondition, not a tuning knob. Without one, ordering a walk over every ref forces git to parse every commit object before it can emit the first — 6.6 s on linux, and limiting the walk does not avoid it because the cost is paid up front. muit writes one in the background on open and keeps it current.

The first viewport comes from SQLite. The cache is keyed on a fingerprint of the repository's refs, because a walk is a pure function of the refs it starts from: an unchanged fingerprint means the cached rows are exactly what git is about to produce, and a changed one means they are worthless. There is nothing in between to be wrong about. That is the 2 ms.

The walk streams and never blocks the UI. git's output is read through a pipe and parsed off raw bytes; rows are laid out as they arrive and published in batches. All of it runs on a background thread, because parsing and laying out a million commits is several seconds of processor time and on the UI thread that time comes out of the frames you are scrolling through.

The remaining 850 ms is git's own. git log --all --topo-order takes that long to emit its first commit on linux even with a commit-graph; the same command in a shell measures the same. It could be brought to ~15 ms by dropping --topo-order, at the cost of the guarantee that a parent never precedes its child — the graph would draw wrongly wherever committer clocks are skewed. muit keeps the guarantee, shows a skeleton, and the cache erases the wait from every open after the first.


How it works

Muit.Core   domain types, IGitClient, ErrorOr results   — depends on nothing
Muit.Git    the git CLI: process runner, streaming parsers
Muit.Data   EF Core + SQLite, and a raw-ADO commit cache
Muit.App    Avalonia views, view models, composition root

git through its command line

muit shells out to the git you already have rather than linking a library. That inherits your commit-graph, your fsmonitor, your credential helpers and your SSH configuration, and it means no native library has to be shipped per platform. History arrives as IAsyncEnumerable<GitCommit> so drawing starts on the first few hundred commits while the rest are still arriving.

Commit ids are a 20-byte struct rather than a 40-character string. At a million commits that is the difference between 20 bytes and about 106 per id.

The EF Core seam

EF Core's change tracker cannot absorb a million commit rows at any acceptable speed. So it is used where migrations and typed queries earn their keep — settings, the repository registry, pins — and the commit cache is a raw Microsoft.Data.Sqlite writer rebinding parameters on one prepared statement inside batched transactions, at roughly 250,000 rows a second. WAL mode lets the background fill keep writing while the UI reads.

Drawing the graph

Lanes are assigned incrementally as commits stream in, matching each arriving commit against a list of open lanes. That is what allows drawing to begin before the walk finishes. Lanes are never renumbered — a freed slot is reused rather than compacted — so a line's column stays put and branches do not slide sideways while you scroll.

Rendering is a custom Control drawing only the rows that intersect the viewport. An ItemsControl, virtualising or not, pays layout and template costs per row that a straight draw does not.

Diffs and merges

A commit's file list comes from git log -1 --raw --numstat -m --first-parent, not diff-tree, which prints nothing at all for a merge unless coerced and then prints one section per parent. --raw and --numstat are asked for together — neither half is sufficient, since only the first carries the change letter and only the second carries the counts — and zipped by position, because git's -z numstat writes a rename as an empty path field followed by two more.

Patch text is fetched per file, on demand, and refused above a size ceiling measured on the bytes before anything is decoded.

The merge editor reads its three sides from the index's unmerged stages and asks git to redo the merge over them with merge-file --diff3. The file on disk carries whatever merge.conflictStyle you have configured — which by default omits the common ancestor entirely — as well as any edits made since the merge stopped. Redoing it gives the same three sides every time, in a format muit chooses rather than one it has to hope for. What kind of conflict a path is in is read from which stages the index holds, not from anything git prints.


Build from source

Requires the .NET 10 SDK and git.

git clone ssh://git@git.mmtsc.de/marvinmees/muit.git
cd muit
dotnet run --project src/Muit.App

Everything else goes through Cake Frosting:

dotnet run --project build/Build -- --target=Test        # build and run every test
dotnet run --project build/Build -- --target=Publish     # all five platforms into artifacts/
dotnet run --project build/Build -- --target=Publish --runtime=linux-x64
dotnet run --project build/Build -- --target=Clean
Target What it does
Clean Removes bin/, obj/ and artifacts/
Restore Restores the solution
Build Builds the solution
Test Runs every test project
Publish Publishes and packages each platform
Release Publishes, then creates a Forgejo release and uploads the artifacts

Useful arguments: --configuration=Debug, --runtime=<rid>, --app-version=1.2.3. (--app-version, not --version — Cake's own CLI owns that one.)

What ships

Self-contained, single-file, ReadyToRun, untrimmed. Trimming is off because EF Core annotates its own DbContext as unsafe to trim, and muit applies migrations at runtime — the most reflection-heavy path in that library. Suppressing the warning would only move the failure onto a user's machine.

The shape was chosen by measurement, on linux-x64:

Artifact Startup
ReadyToRun (shipped) 160 MB ~85 ms
plain 104 MB ~145 ms
plain + compressed 51 MB ~186 ms
ReadyToRun + compressed 71 MB ~235 ms

Compression's penalty recurs on every launch, not just the first. Compressed archives are 63–72 MB, so the download is well under the on-disk figure.

All five platforms cross-compile from one Linux machine in about two minutes.


Testing

204 tests, all against real things: real temporary git repositories driven through the git CLI, a real SQLite file on disk, and real Avalonia rendering through the headless platform with Skia.

dotnet run --project build/Build -- --target=Test

Bad paths are tested as deliberately as happy ones — git missing from PATH, a directory that is not a repository, an unborn branch, detached HEAD, an empty repository, no remote, a merge stopped with conflicts, a repository deleted while its tab is open, non-UTF-8 filenames, a patch too large to render, a binary file, a file with no trailing newline, a conflict in something that is not text.

Two notes on how the suite is run:

  • Tests execute through their own hosts (dotnet run per test project), not dotnet test. TUnit builds on Microsoft.Testing.Platform, and on SDK 10.0.111 the dotnet test server handshake fails in a way that reports zero tests with nothing failing. Running the hosts directly avoids the protocol entirely and is the documented entry point.
  • UI checks render offscreen. Set MUIT_SHOT_DIR to a directory to write PNGs of the views, and additionally MUIT_SHOT_REPO to point the repository view at a real clone. Nothing opens a window.

Continuous integration and delivery

Both run on a self-hosted Forgejo instance, on a runner labelled linux.

CI (.forgejo/workflows/ci.yml) — every pull request to main. One job runs the tests; a second publishes all five artifacts, so a tag is never the first time anyone finds out that packaging is broken.

CD (.forgejo/workflows/cd.yml) — a v* tag. Takes the version from the tag, runs the full chain including tests, publishes all five platforms, and creates the release through Forgejo's Gitea-compatible API.

The only secret is RELEASE_TOKEN, which needs write access to releases on this repository and nothing else.


Credentials and privacy

muit stores no credentials. Authentication is delegated wholesale to git: ssh-agent, your configured credential.helper, your platform keychain. muit has no code that persists a secret, because it has no secrets to persist.

When git has nothing cached and needs to ask, muit acts as its own GIT_ASKPASS helper. git starts the same executable, which recognises the invocation and relays the prompt to the running instance over a named pipe. The answer comes back the same way. The secret is never a command-line argument, an environment variable, or a file.

GIT_TERMINAL_PROMPT=0 is set on every git invocation by default, so no background command can silently hang waiting for input you cannot see. It is re-enabled for exactly one command: the one holding an open prompt.

muit makes no network connections of its own. Everything that reaches a remote is a git command you asked for.


Contributing

Issues and pull requests are welcome on the Forgejo instance.

A few conventions worth knowing before you start:

  • Warnings are errors. TreatWarningsAsErrors is on across the solution.
  • Explain the non-obvious in comments, not the obvious. The codebase documents why a thing is the way it is — the measurement, the failure it avoids, the alternative that was tried. It does not restate what the code says.
  • Test against real things. A fake git would only confirm that the parser agrees with our assumptions about git's output. Where those assumptions turned out to be wrong — rename records spanning two NUL tokens, a merge conflict reported on stdout rather than by exit code — a fake would have passed happily.
  • Measure before optimising, and write the number down. Several decisions here are the opposite of the obvious one, and the measurement is the only reason to trust them. PLAN.md records the ones that shaped the architecture.
  • Commits are signed (git commit -s).

Built with Avalonia · Copyright © Marvin Mees