Skip to main content

Git System

Opal Editor’s Git system provides full Git version control running entirely in the browser using isomorphic-git. The GitRepo class wraps isomorphic-git with a clean API and integrates with Opal’s disk abstraction layer.

Architecture

┌──────────────────────────────────────┐
│           GitRepo                    │
│  (Git Operations & State)            │
├──────────────────────────────────────┤
│  WatchPromiseMembers                 │
│  (Operation Tracking)                │
├──────────────────────────────────────┤
│  isomorphic-git                      │
│  (Core Git Implementation)           │
├──────────────────────────────────────┤
│  HideFs (FileSystem Adapter)         │
│  (Filters special directories)       │
├──────────────────────────────────────┤
│  Disk (Storage Layer)                │
└──────────────────────────────────────┘

Core Class: GitRepo

Location: ~/workspace/source/src/features/git-repo/GitRepo.ts

Class Definition

export class GitRepo {
  disk: Disk;
  dir: AbsPath;
  defaultMainBranch: string;
  readonly guid: string;
  mutex: Mutex;
  
  // Observable state
  $state: Observable<{
    isPending: boolean;
    info: RepoInfoType | null;
  }>;
  
  // Git wrapper with operation tracking
  readonly git: typeof GIT; // Proxied isomorphic-git
  
  // File system adapter
  get fs(): CommonFileSystem;
  get gitDir(): AbsPath; // /.git
}

Repository Info Type

interface RepoInfoType {
  currentAuthor: GitRepoAuthor;
  currentBranch: string | null;
  bareInitialized: boolean;    // .git exists
  fullInitialized: boolean;    // Has commits
  defaultBranch: string;
  branches: string[];
  remotes: GitRemote[];
  latestCommit: RepoLatestCommit;
  remoteRefs: string[];
  hasChanges: boolean;
  commitHistory: RepoCommit[];
  exists: boolean;
  isMerging: boolean;
  unmergedFiles: AbsPath[];
  conflictingFiles: AbsPath[];
  currentRef: GitRef | null;
  parentOid: string | null;
}

Creating Repositories

Factory Methods

// Create new repo from disk
const repo = GitRepo.FromDisk(
  disk,
  "unique-guid",
  absPath("/"),      // repo directory
  "main",            // default branch
  { name: "...", email: "..." } // author
);

// Initialize and start
await repo.init();

// Or skip listeners for background use
await repo.init({ skipListeners: true });

Initialize Repository

// Ensure repo is initialized (creates .git if needed)
await repo.mustBeInitialized("main");

// Check initialization state
const isBare = await repo.bareInitialized();  // .git exists
const isFull = await repo.fullInitialized();  // has commits

Basic Git Operations

Commit Changes

// Stage files
await repo.add("README.md");
await repo.add(["file1.md", "file2.md"]);

// Commit
const oid = await repo.commit({
  message: "Initial commit",
  author: { name: "Alice", email: "[email protected]" }
});

// Remove from staging
await repo.remove("file.md");
await repo.remove(["file1.md", "file2.md"]);

Check Repository Status

// Check for uncommitted changes
const hasChanges = await repo.hasChanges();

// Get status matrix
const matrix = await repo.statusMatrix();
// Returns: [filepath, head, workdir, stage][]

// Get current repository info
const info = repo.getInfo();
console.log(info.currentBranch);
console.log(info.hasChanges);
console.log(info.latestCommit);

Read Commits

// Get latest commit
const latest = await repo.getLatestCommit();
console.log(latest.oid, latest.message, latest.author);

// Get commit history
const commits = await repo.getCommitHistory({
  depth: 20,
  ref: "main",
  filepath: "README.md" // optional: filter by file
});

// Read specific commit
const commit = await repo.readCommit({ oid: "abc123..." });

// Read commit from ref
const commit = await repo.readCommitFromRef({ 
  ref: "main~2" // 2 commits before main
});

Branch Operations

List and Switch Branches

// List all branches
const branches = await repo.getBranches();

// Get current branch
const current = await repo.currentBranch();
const currentFull = await repo.currentBranch({ fullname: true });
// Returns: "main" or "refs/heads/main"

// Checkout branch
await repo.checkoutRef({ ref: "develop" });

// Checkout with force
await repo.checkoutRef({ ref: "main", force: true });

// Checkout default branch
await repo.checkoutDefaultBranch();

Create and Delete Branches

// Create new branch
const branchName = await repo.addGitBranch({
  branchName: "feature",
  symbolicRef: "main",  // branch from main
  checkout: true        // checkout after create
});

// Delete branch
await repo.deleteGitBranch("old-feature");
// Automatically checks out defaultBranch if deleting current

Branch History

// Remember current branch before checkout
await repo.rememberCurrentBranch();

// Get previously checked out branch
const prevBranch = await repo.getPrevBranch();

Remote Operations

Manage Remotes

// Add remote
const remote: GitRemote = {
  name: "origin",
  url: "https://github.com/user/repo.git",
  gitCorsProxy: "https://cors.isomorphic-git.org",
  authId: "auth-guid-123"
};
await repo.addGitRemote(remote);

// List remotes
const remotes = await repo.getRemotes();
// Returns: GitRemote[] with auth details

// Get specific remote (with auth)
const origin = await repo.getRemote("origin");
if (origin) {
  console.log(origin.RemoteAuth); // RemoteAuthDAO | null
  console.log(origin.onAuth);     // AuthCallback | undefined
}

// Delete remote
await repo.deleteGitRemote("origin");

// Replace remote
await repo.replaceGitRemote(oldRemote, newRemote);

Remote Configuration

// Set CORS proxy for remote
await repo.setGitCorsProxy("origin", "https://proxy.com");
const proxy = await repo.getGitCorsProxy("origin");

// Set auth ID for remote
await repo.setAuthId("origin", "auth-guid");
const authId = await repo.getAuthId("origin");

Fetch, Pull, Push

// Fetch from remote
await repo.fetch({
  url: "https://github.com/user/repo.git",
  remote: "origin",
  corsProxy: "https://cors.isomorphic-git.org",
  onAuth: (url, auth) => {
    return { username: "token", password: "ghp_..." };
  }
});

// Pull changes
await repo.pull({ 
  remote: "origin",
  ref: "main" 
});

// Push changes
await repo.push({
  remote: "origin",
  ref: "main",
  force: false,
  remoteRef: "refs/heads/main"
});

// Get remote refs
const refs = await repo.getRemoteRefs("origin");

Merge Operations

Merge Branches

// Merge branch into current
const result = await repo.merge({
  from: "feature",
  into: "main"
});

// Check result
if (isMergeConflict(result)) {
  console.log("Conflicts:", result.filepaths);
  console.log("Both modified:", result.bothModified);
} else {
  console.log("Merge successful");
}

Handle Merge Conflicts

// Check if merging
const isMerging = await repo.isMerging();

// Get merge state
const mergeHead = await repo.getMergeState();
// Returns: commit oid or null

// Get merge message
const mergeMsg = await repo.getMergeMsg();

// Get conflicting files
const conflicts = await repo.getConflictedFiles();
const unmerged = await repo.getUnmergedFiles();

// Manually resolve and commit
await repo.add(resolvedFiles);
await repo.commit({ message: "Resolve conflicts" });
// Merge state is automatically reset after commit

Reference Resolution

Normalize and Resolve Refs

// Normalize short ref to full ref
const fullRef = await repo.normalizeRef({ ref: "main" });
// Returns: "refs/heads/main"

const remoteRef = await repo.normalizeRef({ ref: "origin/main" });
// Returns: "refs/remotes/origin/main"

// Resolve ref to commit OID
const oid = await repo.resolveRef({ ref: "HEAD" });
const oid2 = await repo.resolveRef({ ref: "main~2" });

// Get HEAD
const headOid = await repo.getHead();

Ref Types

type GitRef = 
  | { value: string; type: "branch" }
  | { value: string; type: "commit" };

const ref = info.currentRef;
if (isBranchRef(ref)) {
  console.log("On branch:", ref.value);
} else if (isCommitRef(ref)) {
  console.log("Detached HEAD:", ref.value);
}

Short Names

// Convert full ref to short name
const short = repo.toShortBranchName("refs/heads/main");
// Returns: "main"

const short2 = repo.toShortBranchName("refs/remotes/origin/main");
// Returns: "main"

Configuration

Repository Config

// Set config value
await repo.setConfig("user.name", "Alice");
await repo.setConfig("user.email", "[email protected]");

// Get config value
const name = await repo.getConfig("user.name");
const email = await repo.getConfig("user.email");

// Set default branch
await repo.setDefaultBranch("main");
const defaultBranch = await repo.getDefaultBranch();

Author Information

// Set author for commits
await repo.setAuthor({
  name: "Alice",
  email: "[email protected]"
});

// Author is stored in repo config
const name = await repo.getConfig("user.name");

Event System

GitRepo uses an event system for cross-tab synchronization.

Listen to Git Events

// Listen to any git operation
const unsub = repo.gitListener(() => {
  console.log("Git operation completed");
});

// Listen to info updates
repo.infoListener((info) => {
  console.log("Current branch:", info.currentBranch);
  console.log("Has changes:", info.hasChanges);
});

// Watch for specific operations
repo.watch(() => {
  console.log("Commit, checkout, or merge completed");
});

// Clean up
unsub();

Observable State

// Access reactive state (Valtio proxy)
const state = repo.$state;

console.log(state.isPending);  // Is operation in progress?
console.log(state.info);       // Current repo info

// State updates automatically when operations complete

Advanced Features

Write Refs Directly

// Write ref to commit
await repo.writeRef({
  ref: "refs/heads/main",
  value: commitOid,
  force: false,
  symbolic: false
});

Reset Repository

// Remove .git and reset state
await repo.reset();

Dispose Repository

// Delete .git directory
await repo.dispose();

Integration Patterns

Auto-sync with Workspace

// In workspace initialization:
workspace.dirtyListener(debounce(() => {
  repo.sync(); // Update repo info
}, 500));

repo.gitListener(() => {
  workspace.disk.triggerIndex(); // Re-index files
});

Check Branch/Tag Existence

const exists = await repo.isBranchOrTag("feature");

Current Ref

const currentOid = await repo.currentRef();

Best Practices

Always Use Mutex for Operations

The GitRepo class uses a mutex internally to prevent race conditions:
// ✅ Good - mutex handled automatically
await repo.commit({ message: "Update" });
await repo.push({ remote: "origin" });

// ❌ Bad - external mutex not needed
const release = await repo.mutex.acquire();
try {
  await repo.commit({ message: "Update" });
} finally {
  release();
}

Handle Merge State

// Always check merge state before operations
if (await repo.isMerging()) {
  throw new Error("Cannot push while merge is in progress");
}
await repo.push({ remote: "origin" });

Normalize Refs

// ✅ Good - handles all ref formats
const fullRef = await repo.normalizeRef({ ref: "main" });
await repo.checkout({ ref: fullRef });

// ❌ Bad - may fail on ambiguous refs
await repo.checkout({ ref: "main" });

Listen to Updates

// Set up listeners before operations
const unsub = repo.infoListener((info) => {
  updateUI(info);
});

try {
  await repo.init();
  // ... operations
} finally {
  unsub();
}

Error Handling

import GIT from "isomorphic-git";

try {
  await repo.merge({ from: "feature", into: "main" });
} catch (error) {
  if (error instanceof GIT.Errors.MergeConflictError) {
    console.log("Conflicts:", error.data.filepaths);
  } else if (error instanceof GIT.Errors.NotFoundError) {
    console.log("Ref not found");
  }
}

Serialization

// To JSON
const json = repo.toJSON();
// Returns: { guid, disk, dir, defaultBranch }

// From JSON
const repo = GitRepo.FromJSON(json);

Build docs developers (and LLMs) love