Skip to content

Git Service Core

dazeb edited this page Sep 17, 2026 · 2 revisions

Git Service Core

src/core/git-service.ts is the single programmatic gateway to git for the whole app: status, staging, commits, branches, sync (push/pull/publish), diff payloads, and worktrees. It is deliberately electron-free — it imports only node:child_process, node:fs, node:path, and shared types — so the Electron main process and the Server Edition boot the exact same implementation. Every call shells out to the system git binary through an argv array, never a shell string, and it is remote-agnostic: GitHub, Gitea, or any origin behave alike.

Responsibilities

Concern Entry points
Repository & path resolution findRepoRoot, resolveRepoPath
Diff payloads (for the diff node) diffInfo, showFromRef, readWorkingTree
Working-tree/index state gitStatus, parseGitStatus, stageChanges, stagedDiff
Commits & history commitChanges, recentCommits
Branches currentBranch, listBranches, createBranch, checkoutBranch, deleteBranch, isValidGitRefName
Sync / remote syncState, parseSyncState, push, pull, publish, remoteUrl, ghAuthed
Worktrees listWorktrees, parseWorktreePorcelain, addWorktree, removeWorktree

Main files

  • src/core/git-service.ts — the entire core described on this page.
  • src/shared/types.ts — GitResult, GitFileChange, GitFileStatus, GitBranchInfo, GitCommitInfo, GitSyncState, GitWorktree are authored there and re-exported by the core (L140-L148), so the renderer and the main process share one vocabulary.
  • src/shared/ipc.ts — the channel inventory; diffInfo: 'diff:info' is the diff entry point declared in the "Node services" block (L61). Git operations are never invoked by hardcoded channel strings anywhere else.
  • src/renderer/src/components/SourceControlPanel.tsx — the primary consumer; it touches git only through window.termsprawl.git.* (L3, L47-L54, L73-L123).

Call chain

flowchart LR
  subgraph Renderer
    P["SourceControlPanel<br/>snap / worktrees / busy / error"]
  end
  subgraph Main["main or server process"]
    H["git IPC handler<br/>composes GitPanelSnapshot"]
    C["core/git-service.ts"]
  end
  P -->|"window.termsprawl.git.*(GitTarget)"| H
  H -->|"cwd — local project"| C
  H -->|"remote — ssh project"| C
  C -->|"execFile('git', argv, { cwd, maxBuffer })"| G["system git binary"]
  G -->|"stdout / stderr / exit code"| C
  C -->|"GitResult"| H
  H -->|"JSON over IPC"| P
Loading

Key nodes:

  • GitTarget is the routing key. The panel builds remote ? { remote } : { cwd } (SourceControlPanel.tsx#L43-L45), so a local folder project sends a working directory and a remote project sends a ProjectRemote; the main process "resolves the repo root on the correct side". For a remote project the same argv contract runs over ssh against the remote tree root (L16-L17).
  • The handler layer is thin by design: it composes primitives from the core (gitStatus + currentBranch + syncState + remoteUrl + ghAuthed) into a GitPanelSnapshot, and fulfils the panel's snapshot / worktrees reads (SourceControlPanel.tsx#L47-L54).
  • The core owns no state. Every function takes a repo root, runs one or two git invocations, and returns a plain value. That is why the Server Edition can reuse it unchanged.

runGit is the single spawn point (L126-L138): it always prepends -c color.ui=false, runs with the caller's cwd, and collects a 32 MB maxBuffer. It resolves rather than rejects — every failure becomes { code, stdout, stderr }, so callers never need try/catch around git.

Operation surface

Every function below ends in one runGit invocation; the argv column is the exact argument vector after the implicit -c color.ui=false.

Function argv Failure behavior
gitStatus status --porcelain [] on non-zero
stagedDiff diff --cached '' on non-zero
currentBranch branch --show-current '' on non-zero
listBranches branch --format=%(HEAD)%09%(refname:short) [] on non-zero
stageChanges add -A -- <paths...> GitResult passthrough
createBranch checkout -b <name> ref-name guard, then git
checkoutBranch checkout <name> ref-name guard, then git
deleteBranch branch -D <name> ref-name guard; force delete
commitChanges commit -m <message> git's own error in stderr
recentCommits log -N --pretty=%h\t%an\t%ad\t%s --date=short []; limit defaults to 20
syncState status -sb --porcelain=1 neutral { upstream: null, ahead: 0, behind: 0 }
push / pull push / pull GitResult passthrough
publish push -u origin <currentBranch> origin is hardcoded
remoteUrl remote get-url origin null on non-zero
listWorktrees worktree list --porcelain [] on non-zero
addWorktree worktree add [-b <branch>] <path> branch guarded; path must not exist
removeWorktree worktree remove [--force] <path> force is opt-in

ghAuthed() is the one exception: it uses spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }) and returns status === 0 (L316-L319). It runs no git and blocks, but nothing else in this module depends on it. The panel uses it only to render the "pushing to GitHub needs you logged in" hint when the remote contains github.com (SourceControlPanel.tsx#L125, L160-L162).

Key state

The core is stateless; the meaningful state lives in shared shapes and in the panel's local guards.

  • GitResult { code, stdout, stderr } — the universal op result. runGit normalizes spawn failures to code ?? 1, which is what lets the panel's run() helper branch on res.code !== 0 and surface stderr as the error line (SourceControlPanel.tsx#L60-L71).
  • GitFileChange { path, status, staged } — one row per porcelain entry; drives the stage/unstage toggle.
  • GitSyncState { upstream, ahead, behind } — drives the ↑N / ↓M badge next to the branch name (SourceControlPanel.tsx#L174-L179).
  • GitBranchInfo { name, current }, GitCommitInfo { hash, author, date, subject } — list payloads.
  • GitWorktree { path, branch, head } — branch stays null for a detached worktree.
  • GitPanelSnapshot — the composed view. Fields consumed in the read range are branch, sync, remote, and ghAuthed; branch === '' is the panel's "not a git repository" signal (SourceControlPanel.tsx#L167-L168).
  • Panel-local guards: busy disables concurrent ops, error / status carry the last outcome, and destructive paths are gated by confirmDiscard and confirmRemoveWt before the op is issued (L36, L40, L120-L123).

Diff resolution

diffInfo(path, base) is a separate entry point from the source-control ops — it feeds the diff node through the diff:info channel — and it is documented as never throwing: errors travel inside the payload so the renderer can render a status line (L87-L116).

sequenceDiagram
  participant D as Diff node (renderer)
  participant M as main: diff:info
  participant S as core: diffInfo()
  D->>M: { path, base: 'staged' | 'HEAD' }
  M->>S: diffInfo(path, base)
  S->>S: findRepoRoot(dirname(path))
  alt no enclosing .git
    S-->>M: { original: null, modified: null, error: NO_REPO }
  else repo root found
    S->>S: showFromRef(root, ':' or 'HEAD', repoPath)
    S->>S: readWorkingTree(root, repoPath)
    alt original and modified both null
      S-->>M: { original: null, modified: null, error: IO }
    else
      S-->>M: { original, modified }
    end
  end
  M-->>D: DiffInfo
Loading

Key steps:

  • findRepoRoot walks upward from dirname(path) and returns the first directory whose .git entry exists, or null at the filesystem root (L34-L42). The check is existsSync, so a .git file — the form used by worktrees and submodules — also terminates the walk.
  • base maps to a ref: 'staged' → ':' (the index), otherwise 'HEAD'. showFromRef builds <ref>:<path> itself, with : treated as a prefix rather than a ref name (L50-L67).
  • resolveRepoPath converts an absolute path to a repo-relative one; a relative path is assumed already repo-relative (L83-L85).
  • DiffInfo carries original (from the ref) and modified (working tree), each null when unavailable. DiffErrorCode declares NO_REPO | MISSING | IO, but only NO_REPO and IO are constructed in this module — MISSING is a reserved code for callers.
  • A path absent from both the ref and the working tree is the only case that yields an error when a repo was found; a path present in only one side is a valid diff (added or deleted).

Safety model

  • No shell strings, ever. Every git invocation is execFile('git', [...]) with a fixed argv array; user input only ever lands inside a single argv element. The file header states this explicitly as a design rule for the core.
  • isValidGitRefName(name) is an inline guard — no extra process — applied by createBranch, checkoutBranch, deleteBranch, and addWorktree when a branch is supplied (L223-L232, L359-L361). Rejections return { code: 128, stderr: 'invalid branch name: …' } without spawning.
    • Rejects: empty, leading - (option injection), .., @{, //, trailing / or ., the literal @, and any control character, space, or one of ~^:?*[\.
  • Destructive operations are opt-in. deleteBranch uses branch -D; removeWorktree only passes --force when the caller asks, and the panel only does so after its inline confirm. Both the code comment and the panel reflect the same contract: force only after the caller has confirmed the discard.

Boundary conditions and known limits

These are the places where the module's "resolve, never throw" style trades precision for resilience:

  • Silent empty results. gitStatus, listBranches, listWorktrees, and recentCommits all return [] on a non-zero exit. A missing repo, a broken index, and a genuinely clean tree can be indistinguishable at the call site; only GitPanelSnapshot.branch === '' lets the panel distinguish "not a repo".
  • runGit's exit-code cast. err.code ?? 1 is cast to a number, but a spawn failure (for example git not on PATH) yields the string 'ENOENT'. The code !== 0 check still fails correctly, but the reported value is not a real exit status.
  • Buffer ceilings. runGit allows 32 MB of output, showFromRef allows 16 MB. A larger payload overflows into the error path — and in showFromRef that error is coalesced into null, i.e. an oversized file in the index becomes an original: null diff rather than an explicit error.
  • showFromRef flattens all git errors to null (L58-L61). Missing-from-ref, a bad ref, and a buffer overflow are the same answer.
  • Porcelain v1 is parsed, not normalized. parseGitStatus takes line[0] / line[1] as index/working-tree state and line.slice(3) as the path (L152-L172). Consequently: paths are passed through verbatim (no unquoting of git's quoted non-ASCII/escaped forms), and a rename entry (R old -> new) arrives as a single path containing the arrow. gitStatus also does not set core.quotepath=false; color.ui=false is the only config override.
  • One line per file, not per side. A file that is both staged and further modified in the working tree (MM) produces exactly one entry with staged: true; the working-tree half of that state is not represented in the change list, so the panel's stage/unstage toggle operates on the whole row. Untracked (??) entries short-circuit to { status: 'untracked', staged: false }.
  • parseSyncState is a partial regex. ^##\s+([^.\s]+)(?:\.\.\.(\S+))?(?:\s+\[ahead\s+(\d+)(?:,\s+behind\s+(\d+))?\])? (L279) is not end-anchored and the branch group excludes ., so:
    • a dotted branch name such as release/1.2 truncates the match and yields { upstream: null, ahead: 0, behind: 0 };
    • a [behind M]-only line (behind without ahead) does not satisfy the optional group, which starts with the literal ahead, so behind is reported as 0. Callers therefore cannot treat behind === 0 as proof of being up to date.
  • publish assumes origin, and takes currentBranch first; if that call fails the branch string is empty and git rejects the resulting push -u origin.
  • addWorktree does not validate or create path. It forwards the string as given and relies on git's own refusal when the path already exists, so callers own path resolution.
  • Remote routing is a caller concern. The core always takes a plain repoRoot; whether that root is local or on an ssh host is decided above it by GitTarget.

Extension points

  • Add a new git operation: write a runGit(repoRoot, [...]) wrapper next to the existing ones, export the result type from src/shared/types.ts if it is new, then add a channel in src/shared/ipc.ts and a window.termsprawl.git.* method. The panel calls nothing else.
  • Add a new write path: stageChanges is the only index-mutating helper in this module. The panel's unstage and discard calls (SourceControlPanel.tsx#L75, L81) have no counterpart here, so index resets and working-tree restores are fulfilled by the main-process handler — that is the layer to extend when adding index/working-tree writes.
  • Change transports: because the core is electron-free and argv-based, an ssh-backed implementation of the same function signatures can be swapped in behind the { remote } target without touching this file's contract.
  • Parser hardening: parseGitStatus, parseSyncState, and parseWorktreePorcelain are exported pure functions, so format changes (porcelain v2 / -z, a corrected sync regex) can be made and tested in isolation from process spawning.
  • AI commit messages do not belong here: the panel obtains one via window.termsprawl.git.commitMessage(target) and drops it into the message box for review (SourceControlPanel.tsx#L92-L107), keeping generation out of the core git surface.

Sources: src/core/git-service.ts, src/core/git-service.ts, src/renderer/src/components/SourceControlPanel.tsx, src/shared/ipc.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally