Git System
Opal Editor’s Git system provides full Git version control running entirely in the browser using isomorphic-git. TheGitRepo 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);
Related Systems
- Workspace System - Repository integration
- Storage System - File system layer
- Build System - Integration with builds