Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
458 changes: 458 additions & 0 deletions git-graph-service.js

Large diffs are not rendered by default.

154 changes: 154 additions & 0 deletions git.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,162 @@ async function fileDiff(dir, filePath) {
return { path: relative, diff, truncated };
}

// --- Git Graph read functions ---------------------------------------------
// Everything below is read-only. Every call adds --no-optional-locks (never
// contend with a write in flight elsewhere) and, for anything that formats
// human-readable output, --no-color/--no-show-signature, so a user's own
// color.ui=always can never leak into text this code goes on to parse.
// Multi-field records use NUL between fields and, for git-log output, \x1e
// between records, the same convention parseCommits() already uses above.

/** Normalize a path for use inside a git argv element (git wants '/' even on Windows). */
function toGitPath(relPath) {
return String(relPath).split(path.sep).join('/').split('\\').join('/');
}

// Node's execFile refuses any argv string containing an actual NUL byte
// ("must be a string without null bytes") — so the *format strings* below
// use git's own textual escape for one (`%x00` for git-log's pretty-format
// engine, `%00` for for-each-ref's own, different, format language), and
// only the *parsed output* (which genuinely does contain raw NUL/RS bytes,
// written by git itself, safe for Node to read back) is split on the real
// characters.
const GG_FIELD_SEP = '\x00';
const GG_RECORD_SEP = '\x1e';
const LOG_FIELD_ESCAPE = '%x00';
const LOG_RECORD_ESCAPE = '%x1e';
const FOR_EACH_REF_FIELD_ESCAPE = '%00';

const LOG_ORDER_FLAGS = { date: '--date-order', 'author-date': '--author-date-order', topo: '--topo-order' };

// A revspec entry starting with '-' is only ever accepted from this fixed
// list (or one of the prefixes below) — anything else risks being read as
// a flag rather than a ref/branch-glob.
const REVSPEC_FLAGS = new Set(['--all', '--branches', '--tags', '--remotes', '--first-parent', '--reflog']);
const REVSPEC_FLAG_PREFIXES = ['--glob=', '--branches=', '--tags=', '--remotes=', '--exclude='];

function assertSafeRevspecEntry(entry) {
if (typeof entry !== 'string' || !entry || entry.includes('\0')) throw new Error('Invalid revision specifier');
if (!entry.startsWith('-')) return entry;
if (REVSPEC_FLAGS.has(entry) || REVSPEC_FLAG_PREFIXES.some(prefix => entry.startsWith(prefix))) return entry;
throw new Error(`Revision specifier '${entry}' is not on the allowed flag list`);
}

function logPrettyFormat() {
return ['%H', '%h', '%P', '%an', '%ae', '%aI', '%cn', '%ce', '%cI', '%s'].join(LOG_FIELD_ESCAPE) + LOG_RECORD_ESCAPE;
}

function parseLogWithParents(output) {
return String(output || '')
.split(GG_RECORD_SEP)
.map(record => record.replace(/^\n+|\n+$/g, ''))
.filter(Boolean)
.map((record) => {
const [hash, shortHash, parentField, authorName, authorEmail, authorDate,
committerName, committerEmail, commitDate, subject] = record.split(GG_FIELD_SEP);
return {
hash, shortHash,
parents: parentField ? parentField.split(' ').filter(Boolean) : [],
authorName, authorEmail, authorDate,
committerName, committerEmail, commitDate,
subject: subject || '',
};
});
}

/**
* Commit list with parent hashes, for graph layout. `order` selects
* `--date-order` (default) / `--author-date-order` / `--topo-order`.
* `revspec` is a ref/hash or array of them (default `['HEAD']`); a leading
* '-' entry must be on the fixed allow-list above. Degrades to `[]` on an
* unborn HEAD rather than throwing.
*/
async function logWithParents(dir, { revspec, order = 'date', skip, limit } = {}) {
const orderFlag = LOG_ORDER_FLAGS[order] || LOG_ORDER_FLAGS.date;
const revArgs = [].concat(revspec == null ? ['HEAD'] : revspec).map(assertSafeRevspecEntry);
const args = ['--no-optional-locks', 'log', '--no-color', '--no-show-signature', orderFlag];
if (Number(skip) > 0) args.push(`--skip=${Math.floor(Number(skip))}`);
if (Number(limit) > 0) args.push(`--max-count=${Math.floor(Number(limit))}`);
args.push(`--pretty=format:${logPrettyFormat()}`, ...revArgs);
return parseLogWithParents(await runOr(args, dir, ''));
}

const FOR_EACH_REF_FORMAT = ['%(HEAD)', '%(refname)', '%(objectname)', '%(*objectname)', '%(objecttype)', '%(upstream)'].join(FOR_EACH_REF_FIELD_ESCAPE);

/**
* Local branches, remote-tracking branches, and tags, via `for-each-ref`.
* Each head carries `isHead` (which head is HEAD — not the same as which
* commit HEAD points at while detached). Degrades to empty arrays on a
* repo with no refs at all rather than throwing.
*/
async function forEachRef(dir) {
const output = await runOr(['--no-optional-locks', 'for-each-ref', '--no-color', `--format=${FOR_EACH_REF_FORMAT}`,
'refs/heads', 'refs/remotes', 'refs/tags'], dir, '');
const heads = [];
const remotes = [];
const tags = [];
for (const line of String(output).split('\n')) {
if (!line) continue;
const [headMarker, refname, objectName, peeledObjectName, objectType, upstream] = line.split(GG_FIELD_SEP);
if (refname.startsWith('refs/heads/')) {
heads.push({
name: refname.slice('refs/heads/'.length),
hash: objectName,
isHead: headMarker === '*',
upstream: upstream ? upstream.replace(/^refs\/remotes\//, '') : null,
});
} else if (refname.startsWith('refs/remotes/')) {
const rest = refname.slice('refs/remotes/'.length);
const slash = rest.indexOf('/');
if (slash === -1) continue;
const name = rest.slice(slash + 1);
if (name === 'HEAD') continue; // the remote's own default-branch pointer, not a real branch
remotes.push({ remote: rest.slice(0, slash), name, hash: objectName });
} else if (refname.startsWith('refs/tags/')) {
const annotated = objectType === 'tag';
tags.push({ name: refname.slice('refs/tags/'.length), hash: annotated ? (peeledObjectName || objectName) : objectName, annotated });
}
}
return { heads, remotes, tags };
}

const STASH_FORMAT = ['%H', '%gd', '%gs', '%aI'].join(LOG_FIELD_ESCAPE);
const STASH_MESSAGE_BRANCH_RE = /^(?:WIP on|On) (.+):/;

/**
* Stashes, each with `baseCommitHash` — the commit it was taken from,
* resolved as `<stashHash>^1` (its own first parent) so a stash can be laid
* into the graph as a pseudo-commit attached to that commit.
*/
async function stashList(dir) {
const output = await runOr(['--no-optional-locks', 'stash', 'list', '--no-color', `--format=${STASH_FORMAT}`], dir, '');
const entries = String(output).split('\n').filter(Boolean).map((line) => {
const [hash, gd, message, date] = line.split(GG_FIELD_SEP);
const indexMatch = /stash@\{(\d+)\}/.exec(gd || '');
const branchMatch = STASH_MESSAGE_BRANCH_RE.exec(message || '');
return { hash, index: indexMatch ? Number(indexMatch[1]) : null, branch: branchMatch ? branchMatch[1] : null, message: message || '', date };
});
const bases = await Promise.all(entries.map(entry => runOr(['--no-optional-locks', 'rev-parse', `${entry.hash}^1`], dir, '')));
return entries.map((entry, i) => ({ ...entry, baseCommitHash: bases[i].trim() || null }));
}

/**
* File content at a revision (a blob, via `git show <rev>:<path>`), for the
* diff viewer. `--no-textconv` so a repo's own .gitattributes filter can't
* substitute different bytes than what's actually stored. `relPath` goes
* through the same containment check as fileDiff(), then is normalized to
* '/'-separated (git's pathspec syntax wants that even on Windows).
*/
async function blobAtRevision(dir, rev, relPath) {
if (typeof rev !== 'string' || !rev || rev.includes('\0') || rev.startsWith('-')) throw new Error('Invalid revision');
const { relative } = safeRelativePath(dir, relPath);
return runRaw(['--no-optional-locks', 'show', '--no-color', '--no-textconv', `${rev}:${toGitPath(relative)}`], dir);
}

module.exports = {
run, version, isGitRepo, repoRoot, branchExists,
worktreeAdd, worktreeRemove, isDirtyWorktreeError, gitCommonDir, status,
parsePorcelain, snapshot, fileDiff,
toGitPath, safeRelativePath,
logWithParents, forEachRef, stashList, blobAtRevision,
};
17 changes: 17 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,14 @@ projects.initPlanWatch({
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args);
},
});
// --- Git Graph tab: read-only commit graph service ---
const gitGraphService = require('./git-graph-service');
gitGraphService.init({
log,
send: (channel, ...args) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args);
},
});

// --- IPC: browse-folder ---
ipcMain.handle('browse-folder', async () => {
Expand Down Expand Up @@ -470,6 +478,15 @@ ipcMain.handle('get-project-git-status', guarded((id, opts) => projects.folderGi
ipcMain.handle('get-project-git-info', guarded((id) => projects.projectGitInfo(id)));
ipcMain.handle('get-project-git-diff', guarded((id, folderPath, filePath) => projects.projectGitDiff(id, folderPath, filePath)));
ipcMain.handle('get-folder-git-status', guarded((folderPath) => projects.folderGitInfo(String(folderPath || ''))));

// --- IPC: Git Graph tab (read-only) ---
// Every handler delegates one line into projects.js, same shape as the four
// git handlers above; projects.js re-checks the attached-folder boundary.
ipcMain.handle('get-project-git-graph', guarded((id, folderPath, opts) => projects.projectGitGraph(id, String(folderPath || ''), opts || {})));
ipcMain.handle('get-git-graph-commit-detail', guarded((id, folderPath, hash) => projects.projectGitGraphCommitDetail(id, String(folderPath || ''), hash)));
ipcMain.handle('get-git-graph-compare-detail', guarded((id, folderPath, fromHash, toHash) => projects.projectGitGraphCompareDetail(id, String(folderPath || ''), fromHash, toHash)));
ipcMain.handle('get-git-graph-file-at-revision', guarded((id, folderPath, rev, filePath) => projects.projectGitGraphFileAtRevision(id, String(folderPath || ''), rev, filePath)));
ipcMain.handle('get-git-graph-file-diff-between', guarded((id, folderPath, fromRev, toRevOrNull, filePath) => projects.projectGitGraphFileDiffBetween(id, String(folderPath || ''), fromRev, toRevOrNull, filePath)));
// The .env files a folder has, and the ones the dialog ticks by default,
// so a new worktree can be offered its repository's local environment.
ipcMain.handle('list-env-files', guarded((folderPath) => ({
Expand Down
10 changes: 10 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ contextBridge.exposeInMainWorld('api', {
getProjectGitInfo: (id) => ipcRenderer.invoke('get-project-git-info', id),
getProjectGitDiff: (id, folderPath, filePath) => ipcRenderer.invoke('get-project-git-diff', id, folderPath, filePath),
getFolderGitStatus: (folderPath) => ipcRenderer.invoke('get-folder-git-status', folderPath),

// Git Graph tab (read-only)
getProjectGitGraph: (id, folderPath, opts) => ipcRenderer.invoke('get-project-git-graph', id, folderPath, opts),
getGitGraphCommitDetail: (id, folderPath, hash) => ipcRenderer.invoke('get-git-graph-commit-detail', id, folderPath, hash),
getGitGraphCompareDetail: (id, folderPath, fromHash, toHash) => ipcRenderer.invoke('get-git-graph-compare-detail', id, folderPath, fromHash, toHash),
getGitGraphFileAtRevision: (id, folderPath, rev, filePath) => ipcRenderer.invoke('get-git-graph-file-at-revision', id, folderPath, rev, filePath),
getGitGraphFileDiffBetween: (id, folderPath, fromRev, toRevOrNull, filePath) => ipcRenderer.invoke('get-git-graph-file-diff-between', id, folderPath, fromRev, toRevOrNull, filePath),
onGitGraphRepoChanged: (callback) => {
ipcRenderer.on('git-graph-repo-changed', (_event, folderPath) => callback(folderPath));
},
listEnvFiles: (folderPath) => ipcRenderer.invoke('list-env-files', folderPath),
saveProjectBrief: (id, content) => ipcRenderer.invoke('save-project-brief', id, content),
createProjectFile: (id, name, content) => ipcRenderer.invoke('create-project-file', id, name, content),
Expand Down
45 changes: 45 additions & 0 deletions projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const os = require('os');
const path = require('path');
const crypto = require('crypto');
const git = require('./git');
const gitGraphService = require('./git-graph-service');
const planParser = require('./public/plan-parser');
const sessionConfig = require('./public/session-config');

Expand Down Expand Up @@ -1785,11 +1786,55 @@ async function projectGitDiff(projectId, folderPath, filePath) {
return { ok: true, ...(await git.fileDiff(folder.path, filePath)) };
}

// --- Git Graph tab (read-only): every function re-checks the exact same
// attached-folder boundary as projectGitDiff above before doing anything
// with folderPath. Payload assembly lives one layer down in
// git-graph-service.js; this section stays a thin boundary. ---

/** Throws the same way projectGitDiff does when folderPath is not attached to projectId. */
function requireAttachedFolder(projectId, folderPath) {
if (!db.getProject(projectId)) throw new Error('Project not found');
const folder = db.listProjectFolders(projectId).find(item => item.path === folderPath);
if (!folder) throw new Error('Repository is not attached to this project');
return folder;
}

/** Commits+refs+stashes+uncommitted for the Git Graph tab, paged. */
async function projectGitGraph(projectId, folderPath, opts) {
const folder = requireAttachedFolder(projectId, folderPath);
gitGraphService.ensureRepoWatch(folder.path).catch(() => {});
return gitGraphService.getProjectGitGraph(folder.path, opts || {});
}

/** Full detail (body, file list) for one commit. */
async function projectGitGraphCommitDetail(projectId, folderPath, hash) {
const folder = requireAttachedFolder(projectId, folderPath);
return gitGraphService.getGitGraphCommitDetail(folder.path, hash);
}

/** File list for the Comparison view between two revisions (or the working tree). */
async function projectGitGraphCompareDetail(projectId, folderPath, fromHash, toHash) {
const folder = requireAttachedFolder(projectId, folderPath);
return gitGraphService.getGitGraphCompareDetail(folder.path, fromHash, toHash);
}

async function projectGitGraphFileAtRevision(projectId, folderPath, rev, filePath) {
const folder = requireAttachedFolder(projectId, folderPath);
return gitGraphService.getGitGraphFileAtRevision(folder.path, rev, filePath);
}

async function projectGitGraphFileDiffBetween(projectId, folderPath, fromRev, toRevOrNull, filePath) {
const folder = requireAttachedFolder(projectId, folderPath);
return gitGraphService.getGitGraphFileDiffBetween(folder.path, fromRev, toRevOrNull, filePath);
}

module.exports = {
init,
projectsRoot, slugify, uniqueSlug, defaultBrief,
createProject, updateProject, deleteProject, attachFolder, detachFolder,
folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff,
projectGitGraph, projectGitGraphCommitDetail, projectGitGraphCompareDetail,
projectGitGraphFileAtRevision, projectGitGraphFileDiffBetween,
syncProjectBrief, syncAllProjectBriefs, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, listRecentProjectFiles,
launchContext, mergeAddDirs, worktreeParentFor,
PROJECT_FILES, ADDED_FILES_DIR,
Expand Down
5 changes: 4 additions & 1 deletion public/codemirror-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ function createMergeViewer(parent, originalContent, modifiedContent, filename) {
});
}

function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename) {
function createUnifiedMergeViewer(parent, originalContent, modifiedContent, filename, { readOnly = false } = {}) {
const langExt = getLanguageExt(filename);
const state = EditorState.create({
doc: modifiedContent,
Expand All @@ -516,12 +516,15 @@ function createUnifiedMergeViewer(parent, originalContent, modifiedContent, file
dracula,
syntaxHighlighting(markdownExtras),
appThemePatch,
readOnly ? EditorView.editable.of(false) : [],
readOnly ? EditorState.readOnly.of(true) : [],
unifiedMergeView({
original: originalContent,
gutter: true,
highlightChanges: true,
syntaxHighlightDeletions: true,
collapseUnchanged: { margin: 3, minSize: 4 },
mergeControls: !readOnly,
}),
],
});
Expand Down
Loading