Skip to content
Merged
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
6 changes: 5 additions & 1 deletion apps/desktop/src/main/lib/workspace-ipcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,9 +625,13 @@ export function registerWorkspaceIPCs() {
};
}

// Detect main branch instead of using workspace.branch
// This ensures we compare against main/master, not a feature branch
const mainBranch = await worktreeManager.detectMainBranch(workspace.repoPath);

return await worktreeManager.getGitDiffFile(
worktree.path,
workspace.branch,
mainBranch,
input.filePath,
input.oldPath,
input.status,
Expand Down
175 changes: 168 additions & 7 deletions apps/desktop/src/main/lib/worktree-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,11 +690,99 @@ class WorktreeManager {
}

/**
* Get file list with stats (without full diff content) - non-blocking
* Detect the main branch of a repository
* Tries: main, master, then git's default branch
*/
async detectMainBranch(repoPath: string): Promise<string> {
// Try common main branch names
const candidates = ["main", "master"];

for (const candidate of candidates) {
try {
await execAsync(`git rev-parse --verify ${candidate}`, {
cwd: repoPath,
});
return candidate;
} catch {
// Try remote version
try {
await execAsync(`git rev-parse --verify origin/${candidate}`, {
cwd: repoPath,
});
return `origin/${candidate}`;
} catch {
// Continue to next candidate
}
}
}

// Fallback: try to get git's default branch
try {
const result = await execAsync(
"git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'",
{ cwd: repoPath },
);
const defaultBranch = result.stdout.trim();
if (defaultBranch) {
return defaultBranch;
}
} catch {
// Ignore errors
}

// Last resort: return "main" and let caller handle the error
return "main";
}

/**
* Check if a file should be excluded from diff (build artifacts, dependencies, etc.)
*/
private shouldExcludeFile(filePath: string): boolean {
const excludePatterns = [
// Dependencies
/node_modules/,
/\.pnp\./,
/\.yarn\//,
// Build outputs
/\/dist\//,
/\/dist-ssr\//,
/\/dist-electron\//,
/\/build\//,
/\/\.next\//,
/\/out\//,
/\/release\//,
/\/coverage\//,
// Lock files
/package-lock\.json$/,
/yarn\.lock$/,
/pnpm-lock\.yaml$/,
// Environment files
/\.env$/,
/\.env\./,
// Editor files
/\.DS_Store$/,
/\.vscode\//,
/\.idea\//,
// Cache files
/\.turbo\//,
/\.eslintcache$/,
/\.tsbuildinfo$/,
// Test results
/\/test-results\//,
/\/playwright-report\//,
/\/playwright\/\.cache\//,
// Temporary files
/\.tmp\//,
// Electron build artifacts
/electron\.vite\.config\.\d+\.mjs$/,
];

return excludePatterns.some((pattern) => pattern.test(filePath));
}

async getGitDiffFileList(
worktreePath: string,
mainBranch: string,
_configuredBranch: string,
): Promise<{
success: boolean;
files?: Array<{
Expand All @@ -709,16 +797,53 @@ class WorktreeManager {
error?: string;
}> {
try {
// Detect the actual main branch (main/master) instead of using configured branch
// This ensures we always compare against the main branch, not a feature branch
// Get the actual git repository root (works for both regular repos and worktrees)
let repoPath = worktreePath;
try {
const gitDirResult = await execAsync("git rev-parse --show-toplevel", {
cwd: worktreePath,
});
repoPath = gitDirResult.stdout.trim();
} catch {
// Fallback: try to find .git directory
let currentPath = worktreePath;
while (currentPath !== "/" && currentPath !== "") {
if (existsSync(path.join(currentPath, ".git"))) {
repoPath = currentPath;
break;
}
currentPath = path.dirname(currentPath);
}
}

const mainBranch = await this.detectMainBranch(repoPath);

// Verify mainBranch exists
try {
await execAsync(`git rev-parse --verify ${mainBranch}`, {
cwd: worktreePath,
});
} catch {
return {
success: false,
error: `Main branch "${mainBranch}" not found in repository`,
};
}

// Get list of changed files with status
// Using three-dot syntax to show only committed changes (HEAD vs mainBranch)
const diffFilesResult = await execAsync(
`git diff ${mainBranch} --name-status`,
`git diff ${mainBranch}...HEAD --name-status`,
{ cwd: worktreePath },
);

const fileLines = diffFilesResult.stdout
.trim()
.split("\n")
.filter(Boolean);

const files = [];

for (let i = 0; i < fileLines.length; i++) {
Expand All @@ -728,6 +853,11 @@ class WorktreeManager {
const filePath = parts[1];
const oldPath = parts[2]; // For renamed files

// Check if file should be excluded
if (this.shouldExcludeFile(filePath) || (oldPath && this.shouldExcludeFile(oldPath))) {
continue;
}

// Determine status
let status: "added" | "deleted" | "modified" | "renamed" = "modified";
if (statusCode.startsWith("A")) status = "added";
Expand Down Expand Up @@ -756,7 +886,7 @@ class WorktreeManager {
deletions = parseInt(statParts[1], 10) || 0;
}
}
} catch (statError) {
} catch {
// If stat fails, try to get from shortstat
try {
const shortStatResult = await execAsync(
Expand Down Expand Up @@ -823,10 +953,11 @@ class WorktreeManager {
}> {
try {
// Get detailed diff for this file
// Use three-dot syntax to show only committed changes (HEAD vs mainBranch)
const diffCommand =
status === "deleted"
? `git diff ${mainBranch} -- "${filePath}"`
: `git diff ${mainBranch} -- "${oldPath || filePath}"`;
? `git diff ${mainBranch}...HEAD -- "${filePath}"`
: `git diff ${mainBranch}...HEAD -- "${oldPath || filePath}"`;

const fileDiffResult = await execAsync(diffCommand, {
cwd: worktreePath,
Expand All @@ -847,6 +978,16 @@ class WorktreeManager {
let newLineNum = 0;

const diffLines = diffOutput.split("\n");
// Only include 3 lines of context around changes to reduce data size
// This dramatically reduces memory usage for large files with few changes
const CONTEXT_LINES = 3;
let contextBuffer: Array<{
type: "unchanged";
oldLineNumber: number;
newLineNumber: number;
content: string;
}> = [];

for (let i = 0; i < diffLines.length; i++) {
const line = diffLines[i];

Expand All @@ -857,6 +998,8 @@ class WorktreeManager {
oldLineNum = parseInt(match[1], 10);
newLineNum = parseInt(match[2], 10);
}
// Flush context buffer when starting a new hunk
contextBuffer = [];
continue;
}

Expand All @@ -872,6 +1015,13 @@ class WorktreeManager {

// Parse actual changes
if (line.startsWith("+")) {
// Flush context buffer before adding change
if (contextBuffer.length > 0) {
// Only include up to CONTEXT_LINES from buffer
const contextToInclude = contextBuffer.slice(-CONTEXT_LINES);
changes.push(...contextToInclude);
contextBuffer = [];
}
changes.push({
type: "added",
oldLineNumber: null,
Expand All @@ -880,6 +1030,12 @@ class WorktreeManager {
});
newLineNum++;
} else if (line.startsWith("-")) {
// Flush context buffer before removing change
if (contextBuffer.length > 0) {
const contextToInclude = contextBuffer.slice(-CONTEXT_LINES);
changes.push(...contextToInclude);
contextBuffer = [];
}
changes.push({
type: "removed",
oldLineNumber: oldLineNum,
Expand All @@ -888,12 +1044,17 @@ class WorktreeManager {
});
oldLineNum++;
} else if (line.startsWith(" ")) {
changes.push({
// Store unchanged lines in buffer (only keep last CONTEXT_LINES)
contextBuffer.push({
type: "unchanged",
oldLineNumber: oldLineNum,
newLineNumber: newLineNum,
content: line.substring(1),
});
// Keep buffer size limited
if (contextBuffer.length > CONTEXT_LINES * 2) {
contextBuffer.shift();
}
oldLineNum++;
newLineNum++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const DiffContent = memo(function DiffContent({
onLoad();
}
}, [file.changes.length, isLoading, onLoad]);

const renderDiffLine = (line: DiffLine, index: number) => {
const getBgColor = () => {
switch (line.type) {
Expand Down Expand Up @@ -93,18 +94,18 @@ export const DiffContent = memo(function DiffContent({
key={index}
className={`flex font-mono text-[13px] leading-relaxed border-l-2 transition-colors ${getBorderColor()} ${getBgColor()}`}
>
<div className="shrink-0 w-10 text-right pr-2 py-0.5 text-zinc-600 bg-[#1a1a1a] border-r border-white/5 select-none">
<div className="shrink-0 w-10 text-right pr-2 text-zinc-600 bg-[#1a1a1a] border-r border-white/5 select-none">
{line.oldLineNumber || ""}
</div>
<div className="shrink-0 w-10 text-right pr-2 py-0.5 text-zinc-600 bg-[#1a1a1a] border-r border-white/5 select-none">
<div className="shrink-0 w-10 text-right pr-2 text-zinc-600 bg-[#1a1a1a] border-r border-white/5 select-none">
{line.newLineNumber || ""}
</div>
<div
className={`shrink-0 w-7 text-center py-0.5 ${getMarkerColor()} select-none font-semibold`}
className={`shrink-0 w-7 text-center ${getMarkerColor()} select-none font-semibold`}
>
{getLinePrefix()}
</div>
<div className={`flex-1 py-0.5 pr-4`}>
<div className={`flex-1 pr-4`}>
<SyntaxHighlighter
language={language}
style={vscDarkPlus}
Expand Down Expand Up @@ -154,15 +155,14 @@ export const DiffContent = memo(function DiffContent({
</div>
)}
<span
className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
file.status === "added"
? "bg-emerald-500/10 text-emerald-400"
: file.status === "deleted"
? "bg-rose-500/10 text-rose-400"
: file.status === "modified"
? "bg-amber-500/10 text-amber-400"
: "bg-white/5 text-zinc-400"
}`}
className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${file.status === "added"
? "bg-emerald-500/10 text-emerald-400"
: file.status === "deleted"
? "bg-rose-500/10 text-rose-400"
: file.status === "modified"
? "bg-amber-500/10 text-amber-400"
: "bg-white/5 text-zinc-400"
}`}
>
{file.status}
</span>
Expand Down
Loading
Loading