- C# 100%
|
All checks were successful
CD / release (push) Successful in 11m24s
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. |
||
|---|---|---|
| .forgejo/workflows | ||
| build/Build | ||
| docs/images | ||
| src | ||
| tests | ||
| .editorconfig | ||
| .gitignore | ||
| Directory.Build.props | ||
| Directory.Packages.props | ||
| global.json | ||
| muit.slnx | ||
| PLAN.md | ||
| README.md | ||
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.
Contents
- What it does
- Install
- Performance
- How it works
- Build from source
- Testing
- Continuous integration and delivery
- Credentials and privacy
- Contributing
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.
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.
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.
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.
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.
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.
Nothing blocks on git
Panels show a skeleton the moment a repository opens and fill in behind it.
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 runper test project), notdotnet test. TUnit builds on Microsoft.Testing.Platform, and on SDK 10.0.111 thedotnet testserver 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_DIRto a directory to write PNGs of the views, and additionallyMUIT_SHOT_REPOto 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.
TreatWarningsAsErrorsis 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.mdrecords the ones that shaped the architecture. - Commits are signed (
git commit -s).







