Git

The OpenEng Git app is a browser-based git workbench backed by a local, sealed engine. The UI lives at git.openeng.app; every git operation actually runs in a small gRPC server bound to 127.0.0.1:50053 on your own machine, against your own working trees and credentials. Nothing about your repositories — paths, diffs, commit messages, refs, or the tokens and SSH keys that authenticate a push — leaves the loopback wire. The browser is a thin renderer; the engine does all the git work with libgit2 and your own git binary.

Overview & architecture

The Git app follows the same "one engine, many surfaces" split as the rest of the OpenEng suite. The web UI issues repo-scoped operations; a locally-running engine executes them and returns structured results. The single boundary between the two is an authenticated, per-frame-encrypted gRPC channel over loopback.

Web UI

Served from git.openeng.app. Renders the workspace overview, repo workbench (Changes / History / Branches), diff and commit-graph panes. Navigation is URL-driven, so refresh and deep-links restore the exact repo, tab, file, or commit.

Local engine

The openeng-git binary running serve on 127.0.0.1:50053. Implements every RPC in git.proto. One engine per machine, coexisting with the AI engine (50051) and terminal engine (50052).

Sealed transport

gRPC-Web over TLS, reached at https://engine.openeng.app:50053. That host resolves to 127.0.0.1 everywhere but carries a publicly-trusted certificate, so the browser connects with no manual trust step while the socket stays pure loopback.

Note The engine makes exactly one outbound cloud call in its lifetime: authenticating you at boot (the OAuth one-time-key exchange). Every git operation after that is local. Your repositories and credentials never traverse the network.

Two backends, chosen per operation

The engine deliberately uses two mechanisms and routes each operation to whichever is stronger:

  • libgit2 (the git2 crate) for read-heavy, structured work: status, diff, log, single-commit detail, branch listing (with ahead/behind), blame, stash listing, tags, remotes, branch create/checkout/delete, and plain (unsigned) commits.
  • Your own git binary (shelled out) for what libgit2 does poorly or not at all, and — crucially — anything touching the network or your signing keys: push, pull, fetch (streamed), merge, stash save/apply/pop/drop, GPG/signoff commits, and cross-history search.

Shelling out to git is what lets remote operations transparently reuse your local credential helper and SSH agent: the child process inherits your environment, so authentication "just works" without a single credential ever crossing the gRPC wire.

Getting connected

Opening git.openeng.app lands on the pairing gate (/pair) until a local engine is reachable and a session is established. If no engine is running, the screen shows a copy-paste command; the app connects automatically the moment the engine comes up (via Retry or the background heartbeat) and returns you to the originally requested URL.

Booting the engine

The pairing screen mints a single-use, short-lived OAuth key and shows a command of the form:

openeng-git serve --oauth=<one-time-key>

OAuth is the only supported boot path — there is no offline or static-key mode. The engine consumes the key, authenticates you, binds loopback, and advertises its endpoint for the browser to discover.

The handshake & per-frame seal

CreateSession is the only unsealed RPC — it bootstraps the seal. The client offers a protocol version and an X25519 public key; the engine replies with its own public key, a session id (ses_<uuid>), its protocol version, and a compatibility verdict. From then on every frame is sealed with X25519 + ChaCha20, so even the loopback payload is encrypted.

The handshake response also carries three display-only fields, read from your global git config and home directory:

FieldSourcePurpose
usergit config user.nameShown in the header; never a credential.
emailgit config user.emailDisplay only.
default_rootYour home directoryPlaceholder for the manual "add repo by path" field.

Protocol compatibility

The wire contract is versioned. The engine currently speaks openeng.git.v1 (version 1.0). Compatibility is major-only: a client and engine with the same major interoperate (minor bumps are additive and backward-compatible), while a major mismatch is fatal and the session response carries human-readable "update your client/engine" guidance in mismatch_reason.

Invariant The schema carries no field for a secret, credential, API key, password, or private key — a shape enforced by a test in the proto crate. The only legitimate payload is your own repo data: paths, diffs, commit messages, and refs.

Managing repositories

Every operation except the handshake is repo-scoped by a stable repo_id. The engine keeps a registry of your working trees, populated two ways and persisted so it survives restarts and is shared across browser tabs.

How repositories enter the registry

  • Detection — a bounded scan (depth 3) of your home directory plus conventional workspace roots (Projects, projects, src, Code, code, dev, Developer, workspace, git, repos, work), with $HOME itself scanned shallowly. The scan skips heavy/noisy directories (node_modules, target, vendor, and top-level dotfolders) and stops descending once it finds a .git, so it stays fast on a large home.
  • ExplicitAddRepo(path) for a directory you point at. The path is validated (it must be a git working tree — have a .git directory/file, or be openable by libgit2) and canonicalized to an absolute path before it is stored.

Each repo's id is the first 16 bytes of the SHA-256 of its absolute path, rendered as 32 hex characters — so the same path always maps to the same id, across restarts and detection runs. The registry (a list of paths) persists to ~/.openeng/git/state/repos.json.

The repository RPCs

RPCReturnsNotes
ListReposEvery registered repo with live factsEach entry carries id, path, name, current branch, dirty flag, and ahead/behind. A facts failure degrades to an identity-only entry rather than failing the whole list.
AddRepoThe new (or existing) repoIdempotent — re-adding a path returns the same entry. Rejects a non-repo with an invalid-argument error.
RemoveReporemoved: boolForgets the repo from the registry only. It never touches the working tree on disk.
GetRepoInfoFacts + remotes + upstreamAdds head_oid, head_summary, the configured remotes, and the upstream ref (e.g. origin/main).

Repo facts

For any repo the engine computes, on demand: the current branch (or the short OID / (detached) for a detached HEAD), a dirty flag (any staged, unstaged, or untracked change), the HEAD OID and summary, the configured upstream, and how many commits the branch is ahead/behind that upstream (via libgit2's graph_ahead_behind).

Native folder picker

Because a browser cannot read your filesystem, the "Add repository" flow can pop the OS-native directory chooser on your machine via the PickFolder RPC, and hand the chosen absolute path to AddRepo. It is fail-soft: a cancelled dialog, a headless host with no display, or any dialog error all return an empty path with cancelled: true — the RPC never errors. On Linux it uses the GTK chooser directly; on macOS it drives osascript as a separate process, because an in-process Cocoa panel needs a main-thread run loop the headless engine doesn't have.

Tip In the app, adding repositories lives under Settings → Repositories: use Add a repository to browse, or enter a path manually to type one relative to your home directory. Detected repos appear automatically.

Working tree: status & diff

Status

GetStatus returns a porcelain-style view grouped into three lists: staged (index differs from HEAD), unstaged (working tree differs from index), and untracked. A single modified-and-staged file legitimately appears in both the staged and unstaged groups, each with its own two-character XY code. Rename detection is enabled on both the head→index and index→worktree sides, so renames surface with an orig_path.

Each entry carries per-file line counts (additions/deletions), computed best-effort — binary files and any counting error degrade to 0/0 rather than failing. Untracked files are reported with the ?? code and no counts.

StatusEntry {
  path       // repo-relative path
  status     // two-char XY code: "M ", " M", "??", "A ", "D ", "R ", "UU", …
  staged     // belongs in the staged group
  untracked
  additions  deletions
  orig_path  // set for renames
}

Diff

GetDiff is a single RPC with three modes, selected by which fields you set:

Fields setDiff produced
commit_oid non-emptyThat commit against its first parent.
staged: trueThe index against HEAD (what a commit would record).
neitherThe working tree against the index (unstaged edits).

An empty path diffs the whole tree; a non-empty path scopes it to one file. Diffs use 3 lines of context and include untracked content. The response is a list of FileDiffDiffHunkDiffLine, where each line has an origin (' ' context, '+' add, '-' delete) and old/new line numbers (0 where not applicable). Binary files are flagged and carry no hunks.

Staging & committing

Stage, unstage, discard

StageFile

Adds a path to the index. A deleted file is handled correctly — it is removed from the index rather than added.

UnstageFile

Resets the index entry back to HEAD. On an unborn branch (no HEAD yet) it simply drops the path from the index.

DiscardFile

Restores the working-tree file from the index/HEAD version (a forced, path-scoped checkout). This is destructive — the working-tree change is gone.

In the workbench Changes tab, each file row has a stage/unstage checkbox, and "stage all" / "unstage all" shortcuts fan the operation across every file in a group. Selecting a file loads its diff in the shared right pane.

Commit

Commit writes a commit from the current index. The engine routes it by whether signing is requested:

  • Plain commits go through libgit2. They require a committer identity — if user.name / user.email are unset the RPC returns a clear invalid-argument error.
  • Signed (gpg_sign) or signoff commits go through the git binary, because libgit2's signing support is patchy. The engine passes --gpg-sign / --signoff and resolves the resulting HEAD OID afterward.

amend rewrites HEAD; an empty message on an amend keeps the previous message (--no-edit). The response carries the new commit's OID.

Note The workbench's commit composer currently sends plain commits only. The amend, signoff, and gpg_sign options exist in the engine and client but are not yet exposed as UI toggles — they are available to API callers today.

History: log & commit detail

GetLog walks history from a given rev (or HEAD), time-sorted, with limit (default 100) and skip paging, and an optional path filter that keeps only commits which touched that path. Each Commit carries its OID and short OID, summary and body, author name/email, an RFC3339 commit time, parent OIDs, and the branch/tag refs that point at it (used to draw the graph).

GetCommit returns a single commit's metadata plus the full per-file diff of that commit against its first parent — the same FileDiff structure used everywhere else.

The workbench renders history two ways from one dataset: a linear commit list in the left navigator and a commit-graph DAG in the right pane, with a commit-detail pane (message + changed files + diffs) when one is selected. The History tab loads up to 120 commits.

Branches

ListBranches returns local and remote branches together. Each Branch reports whether it is remote, whether it is the current branch, its upstream, its tip OID, and — for local branches — how far ahead/behind its upstream it is.

RPCBehavior
CreateBranchCreates a branch at a start_point (or HEAD), optionally checking it out.
CheckoutBranchUpdates HEAD and the working tree (a safe checkout). Resolves a local branch ref, else any revparse-able ref, in which case HEAD goes detached.
DeleteBranchDeletes a local branch. The force flag is currently accepted but ignored — deletion behaves as an ordinary delete.
MergeBranchShells out to git merge (optionally --no-ff). Returns ok, a conflicts flag (detected from the git output), and the combined message.

In the app, the Branches tab lists local branches with a tracking label (↑n / ↓n / synced / local), and clicking a non-current branch checks it out. The "+" opens a create-branch prompt (which checks out the new branch). Branch deletion and merge are implemented in the engine and client but are not yet surfaced in the workbench UI.

Remotes, push, pull & fetch

ListRemotes returns each configured remote's name, fetch URL, and push URL. The three network operations — Push, Pull, Fetch — are server-streaming: the engine shells out to git … --progress and streams its output line by line as ProgressLine events (splitting on both \n and \r so progress repaints arrive as distinct lines), terminated by a single ProgressDone carrying ok, the exit code, and a message.

// The stream shape (mirrors the terminal engine's Run):
ProgressLine*  →  ProgressDone { ok, exit_code, message }

Options

OpOptions honored
Pushset_upstream (--set-upstream), force_with_lease (--force-with-lease), tags (--tags), plus optional remote and ref.
Pullstrategy: merge (--no-rebase), rebase (--rebase), or ff-only (--ff-only).
Fetchall_remotes (--all) and tags (--tags).

Remote authentication

No credential is ever accepted from the caller. The git child inherits your local environment, so your credential helper and SSH agent provide authentication exactly as they would on the command line. The engine sets GIT_TERMINAL_PROMPT=0 and a null stdin, so a missing credential fails fast rather than hanging the engine on a prompt that can never be answered.

Heads up Because prompts are disabled, a repository that would normally ask for a username/password interactively will fail. Configure a credential helper or SSH key so pushes and pulls authenticate non-interactively.

In the app, the workbench header has a one-click Push (with set_upstream), and the Branches tab's active-branch card offers Push and Pull with a live progress log. Fetch is available to API callers but is not yet a dedicated button in the workbench.

Stash

Stash listing uses libgit2 (index, message, OID, and a best-effort created-at timestamp), while save/apply/pop/drop shell out to git stash:

  • ListStash — enumerate stash entries.
  • StashSavegit stash push, optionally --include-untracked and with a message.
  • StashApply — apply or pop by index (the pop flag selects git stash pop stash@{N} vs apply).
  • StashDropgit stash drop stash@{N}.
Not yet in the UI Stash is fully implemented in the engine and typed client, but the workbench does not yet render a stash panel. It is callable via the API today.

Tags

ListTags returns both lightweight and annotated tags, distinguishing them via the annotated flag and surfacing the target OID and (for annotated tags) the message. CreateTag creates an annotated tag when a message is given (which requires a tagger identity) or a lightweight tag otherwise, at an explicit target or HEAD. DeleteTag removes a tag. As with stash, tags are engine- and client-complete but not yet surfaced as a workbench panel.

Blame

GetBlame(path) returns per-line attribution for a file: the line number and content (read from the working-tree file), the commit OID that last touched it, the author, the commit time, and that commit's summary. A path is required. Lines with no blame hunk (e.g. brand-new content) come back with empty attribution but their text intact. Blame is implemented in the engine and client; a blame view is not yet part of the workbench.

Search(query, mode, limit) runs a cross-history search by shelling out to git log with a machine-parseable format, returning matching commits. Four modes:

Modegit mechanismFinds
message--grepCommits whose message matches.
author--authorCommits by an author.
file-- <path>Commits that touched a path.
content-S (pickaxe)Commits that added/removed an occurrence of a string.

The result commits carry OID, summary, author, time, and parents (body and refs are omitted for search results). A failed pattern with no output returns an empty list rather than an error.

Not yet in the UI The ⌘K command palette searches sections and repositories to jump between them; it does not yet drive the history Search RPC. Cross-history search is available to API callers.

Configuration, scopes & data locations

Request scoping

Every RPC except CreateSession carries a session_id and, for repo operations, a repo_id. The engine resolves the id to an absolute working-tree path; an unknown id yields a NotFound status. There are no server-side "scopes" or permission tiers — the engine acts entirely on your local machine with your own filesystem permissions, and the sealed session is what gates access.

Identity & config

The committer/tagger identity comes from your global git config (user.name / user.email); the engine reads it for display and requires it for commits and annotated tags. The engine does not manage git config, credentials, or SSH keys — those remain the domain of your local git installation.

Stale-while-revalidate cache

An engine-owned, file-based key/value cache (CacheGet / CacheSet / CacheDelete) stores the last result of costly reads, each stamped with the wall-clock millisecond it was refreshed. On open the UI renders the cached value instantly with a small "refreshing…" hint, fetches fresh in the background, and writes it back. Because it is file-based, every browser on the machine shares one last-known state. The stored value is opaque UI-encoded JSON — never a secret. The repo list uses this under the key repos:list.

Runtime & state layout

The engine roots its files under ~/.openeng/git/ (override the whole OpenEng home with the OPENENG_HOME env var), keeping it isolated from the AI and terminal engines:

PathContents
~/.openeng/git/runtime/The spawn lock (one engine per machine, via an O_EXCL PID file), the endpoint descriptor, the 0600 handshake token, and the TLS certs/keys.
~/.openeng/git/state/repos.jsonThe persisted list of registered repository paths.
SettingValue
Loopback bind127.0.0.1:50053 (fixed, zero-config)
Browser endpointhttps://engine.openeng.app:50053
UI origingit.openeng.app
Protocol versionopeneng.git.v1 (1.0)
Sibling enginesAI 50051 · Terminal 50052

What is not yet exposed

In the interest of an honest reference, here is what the Git app does not do today:

  • No file-content or tree-read RPC. There is no way to browse a repository's file tree or read a blob at an arbitrary ref. Content is only ever surfaced through diffs (which read the working tree, index, or a commit-vs-parent) and blame (which reads the working-tree file). You cannot open an arbitrary file at HEAD or an old commit.
  • No forge / provider connection. The CI & CD, Requests, and Insights sections are honest "not connected" placeholders — the local engine has no CI, pull/merge requests, or remote host to report. The Connect settings category likewise shows a not-connected state; forge-host and in-app credential/SSH management are planned for a later pass.
  • No conflict-resolution tooling. MergeBranch reports a conflicts flag, but there is no UI to resolve conflicts.
  • UI gaps over an already-capable engine. The engine and typed client fully implement stash, tags, blame, cross-history search, branch delete/merge, fetch, and commit amend/signoff/GPG — but the workbench does not yet render panels or controls for them. They are reachable via the API today and will surface in the UI incrementally.
  • No in-app credential management. Remote authentication relies entirely on your local git credential helper and SSH agent; the app neither stores nor transmits tokens or keys.
Design intent These are surfacing gaps, not architectural ones. The engine's job is to be a complete, local, sealed git backend; the UI exposes that surface progressively. Because the transport carries no secrets and every operation is local, new panels can be added without changing the security model.