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
60 changes: 52 additions & 8 deletions src/main/services/ai/commit-message.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process';
import type { AIProvider, ModelId, ReasoningEffort } from '@shared/types';
import { isWslGitRepository, spawnGit } from '../git/runtime';
import { parseCLIOutput, spawnCLI } from './providers';

export interface CommitMessageOptions {
Expand Down Expand Up @@ -35,12 +36,53 @@ function stripCodeFence(text: string): string {
.trim();
}

function runGit(cmd: string, cwd: string): string {
try {
return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 5000 }).trim();
} catch {
return '';
function runGit(args: string[], cwd: string): Promise<string> {
if (!isWslGitRepository(cwd)) {
try {
return Promise.resolve(
execSync(`git ${args.join(' ')}`, { cwd, encoding: 'utf-8', timeout: 5000 }).trim()
);
} catch {
return Promise.resolve('');
}
}

return new Promise((resolve) => {
let stdout = '';
let settled = false;

const proc = spawnGit(cwd, args);

const timeout = setTimeout(() => {
if (settled) return;
settled = true;
if (!proc.killed) {
proc.kill('SIGKILL');
}
resolve('');
}, 5000);

proc.stdout.on('data', (data) => {
stdout += data.toString('utf-8');
});

// Drain stderr to avoid child process blocking on full pipe buffer.
proc.stderr.on('data', () => {});

proc.on('error', () => {
clearTimeout(timeout);
if (settled) return;
settled = true;
resolve('');
});

proc.on('close', (code) => {
clearTimeout(timeout);
if (settled) return;
settled = true;
resolve(code === 0 ? stdout.trim() : '');
});
});
}

export async function generateCommitMessage(
Expand All @@ -56,9 +98,11 @@ export async function generateCommitMessage(
prompt: customPrompt,
} = options;

const recentCommits = runGit('git --no-pager log -5 --format="%s"', workdir);
const stagedStat = runGit('git --no-pager diff --cached --stat', workdir);
const stagedDiff = runGit('git --no-pager diff --cached', workdir);
const [recentCommits, stagedStat, stagedDiff] = await Promise.all([
runGit(['--no-pager', 'log', '-5', '--format=%s'], workdir),
runGit(['--no-pager', 'diff', '--cached', '--stat'], workdir),
runGit(['--no-pager', 'diff', '--cached'], workdir),
]);

const truncatedDiff =
stagedDiff.split('\n').slice(0, maxDiffLines).join('\n') || '(no staged changes detected)';
Expand Down
25 changes: 21 additions & 4 deletions src/main/services/git/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ import type {
} from '@shared/types';
import type { SimpleGit, StatusResult } from 'simple-git';
import { decodeBuffer, gitShow } from './encoding';
import { createGitEnv, createSimpleGit, spawnGit, toGitPath } from './runtime';
import {
createGitEnv,
createSimpleGit,
isWslGitRepository,
normalizeGitRelativePath,
spawnGit,
toGitPath,
} from './runtime';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -56,6 +63,13 @@ export class GitService {
return createGitEnv(workdir);
}

private normalizePathsForGit(paths: string[]): string[] {
if (!isWslGitRepository(this.workdir)) {
return paths;
}
return paths.map((filePath) => normalizeGitRelativePath(toGitPath(this.workdir, filePath)));
}

private async readPorcelainV2Limited(maxEntries: number): Promise<LimitedGitStatus> {
const branchInfo: PorcelainBranchInfo = {
current: null,
Expand Down Expand Up @@ -405,7 +419,8 @@ export class GitService {

async commit(message: string, files?: string[]): Promise<string> {
if (files && files.length > 0) {
await this.git.add(files);
const normalizedFiles = this.normalizePathsForGit(files);
await this.git.add(normalizedFiles);
}
const result = await this.git.commit(message);
return result.commit;
Expand Down Expand Up @@ -674,11 +689,13 @@ export class GitService {
}

async stage(paths: string[]): Promise<void> {
await this.git.add(paths);
const normalizedPaths = this.normalizePathsForGit(paths);
await this.git.add(normalizedPaths);
}

async unstage(paths: string[]): Promise<void> {
await this.git.raw(['reset', 'HEAD', '--', ...paths]);
const normalizedPaths = this.normalizePathsForGit(paths);
await this.git.raw(['reset', 'HEAD', '--', ...normalizedPaths]);
}

async discard(filePaths: string | string[]): Promise<void> {
Expand Down
3 changes: 1 addition & 2 deletions src/main/services/git/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import {
type SpawnOptionsWithoutStdio,
spawn,
} from 'node:child_process';
import { WSL_UNC_PREFIXES } from '@shared/utils/path';
import simpleGit, { type SimpleGit, type SimpleGitOptions } from 'simple-git';
import { getProxyEnvVars } from '../proxy/ProxyConfig';
import { getEnhancedPath } from '../terminal/PtyManager';
import { withSafeDirectoryEnv } from './safeDirectory';

const WSL_UNC_PREFIXES = ['//wsl.localhost/', '//wsl$/'];

type WslPathInfo = {
host: 'wsl.localhost' | 'wsl$';
distro: string;
Expand Down
7 changes: 4 additions & 3 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
WorktreeMergeOptions,
WorktreeMergeResult,
} from '@shared/types';
import { getPathBasename } from '@shared/utils/path';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Expand Down Expand Up @@ -664,7 +665,7 @@ export default function App() {
}

// Extract repo name from path (handle both / and \ for Windows compatibility)
const name = selectedPath.split(/[\\/]/).pop() || selectedPath;
const name = getPathBasename(selectedPath);

const newRepo: Repository = {
name,
Expand Down Expand Up @@ -699,7 +700,7 @@ export default function App() {
}

// Extract repo name from path
const name = clonedPath.split(/[\\/]/).pop() || clonedPath;
const name = getPathBasename(clonedPath);

const newRepo: Repository = {
name,
Expand Down Expand Up @@ -1095,7 +1096,7 @@ export default function App() {
worktrees={sortedWorktrees}
activeWorktree={activeWorktree}
branches={branches}
projectName={selectedRepo?.split(/[\\/]/).pop() || ''}
projectName={selectedRepo ? getPathBasename(selectedRepo) : ''}
isLoading={worktreesLoading}
isCreating={createWorktreeMutation.isPending}
error={worktreeError}
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/App/hooks/useOpenPathListener.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getPathBasename } from '@shared/utils/path';
import { useEffect } from 'react';
import { pathsEqual } from '../storage';
import type { Repository } from '../constants';
import { pathsEqual } from '../storage';

export function useOpenPathListener(
repositories: Repository[],
Expand All @@ -14,7 +15,7 @@ export function useOpenPathListener(
if (existingRepo) {
setSelectedRepo(existingRepo.path);
} else {
const name = path.split(/[\\/]/).pop() || path;
const name = getPathBasename(path);
const newRepo: Repository = { name, path };
const updated = [...repositories, newRepo];
saveRepositories(updated);
Expand Down
7 changes: 4 additions & 3 deletions src/renderer/App/hooks/useRepositoryState.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getPathBasename } from '@shared/utils/path';
import { useCallback, useEffect, useState } from 'react';
import { normalizeHexColor } from '@/lib/colors';
import {
Expand All @@ -12,9 +13,9 @@ import {
getStoredGroups,
migrateRepositoryGroups,
pathsEqual,
STORAGE_KEYS,
saveActiveGroupId,
saveGroups,
STORAGE_KEYS,
} from '../storage';

export function useRepositoryState() {
Expand All @@ -41,7 +42,7 @@ export function useRepositoryState() {
parsed = parsed.map((repo) => {
if (repo.name.includes('/') || repo.name.includes('\\')) {
needsMigration = true;
const fixedName = repo.path.split(/[\\/]/).pop() || repo.path;
const fixedName = getPathBasename(repo.path);
return { ...repo, name: fixedName };
}
if (repo.groupId && !validGroupIds.has(repo.groupId)) {
Expand Down Expand Up @@ -155,7 +156,7 @@ export function useRepositoryState() {
return;
}

const name = path.split(/[\\/]/).pop() || path;
const name = getPathBasename(path);
const newRepo: Repository = {
name,
path,
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/chat/SessionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ function getSessionDisplayName(session: {
const title = session.terminalTitle;
if (!title) return session.name;
// Skip raw process paths (e.g. "Administrator: C:\...\pwsh.exe")
if (/[/\\](pwsh|powershell|cmd|bash|zsh|sh|fish|nu)(\.exe)?["']?\s*$/i.test(title)) {
if (/[/\\](pwsh|powershell|cmd|bash|zsh|sh|fish|nu|wsl)(\.exe)?["']?\s*$/i.test(title)) {
return session.name;
}
if (/^(Administrator|root)\s*:/i.test(title)) {
Expand Down
12 changes: 8 additions & 4 deletions src/renderer/components/layout/RepositorySidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isWslUncPath, trimTrailingPathSeparators } from '@shared/utils/path';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import {
ChevronRight,
Expand Down Expand Up @@ -309,6 +310,8 @@ export function RepositorySidebar({

const renderRepoItem = (repo: Repository, originalIndex: number, sectionGroupId?: string) => {
const isSelected = selectedRepo === repo.path;
const displayRepoPath = trimTrailingPathSeparators(repo.path);
const useLtrPathDisplay = isWslUncPath(repo.path);
return (
<RepoItemWithGlow key={repo.path} repoPath={repo.path}>
{/* Drop indicator - top */}
Expand Down Expand Up @@ -376,12 +379,13 @@ export function RepositorySidebar({
{/* Path */}
<div
className={cn(
'relative z-10 w-full pl-6 text-xs overflow-hidden whitespace-nowrap text-ellipsis [direction:rtl] [text-align:left]',
isSelected ? 'text-accent-foreground/70' : 'text-muted-foreground'
'relative z-10 w-full pl-6 text-xs overflow-hidden whitespace-nowrap text-ellipsis [text-align:left]',
isSelected ? 'text-accent-foreground/70' : 'text-muted-foreground',
useLtrPathDisplay ? '[direction:ltr]' : '[direction:rtl]'
)}
title={repo.path}
title={displayRepoPath}
>
{repo.path}
{displayRepoPath}
</div>
</button>
{/* Drop indicator - bottom */}
Expand Down
5 changes: 3 additions & 2 deletions src/renderer/components/layout/RunningProjectsPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GitWorktree, TerminalSession } from '@shared/types';
import { getPathBasename } from '@shared/utils/path';
import {
Activity,
Bot,
Expand Down Expand Up @@ -123,8 +124,8 @@ export function RunningProjectsPopover({
return {
path,
repoPath,
repoName: repoPath.split('/').pop() || repoPath,
branchName: worktree?.branch || path.split('/').pop() || path,
repoName: getPathBasename(repoPath),
branchName: worktree?.branch || getPathBasename(path),
worktree,
agents,
terminals,
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/components/layout/TemporaryWorkspacePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TempWorkspaceItem } from '@shared/types';
import { isWslUncPath } from '@shared/utils/path';
import { motion } from 'framer-motion';
import {
FolderGit2,
Expand Down Expand Up @@ -168,6 +169,7 @@ function TemporaryWorkspaceItemRow({
const activities = useWorktreeActivityStore((s) => s.activities);
const activity = activities[item.path] || { agentCount: 0, terminalCount: 0 };
const hasActivity = activity.agentCount > 0 || activity.terminalCount > 0;
const useLtrPathDisplay = isWslUncPath(item.path);

const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -201,7 +203,10 @@ function TemporaryWorkspaceItemRow({
</span>
</div>
<div
className="relative z-10 w-full overflow-hidden whitespace-nowrap text-ellipsis pl-6 text-xs text-muted-foreground [direction:rtl] [text-align:left] [unicode-bidi:plaintext]"
className={cn(
'relative z-10 w-full overflow-hidden whitespace-nowrap text-ellipsis pl-6 text-xs text-muted-foreground [text-align:left] [unicode-bidi:plaintext]',
useLtrPathDisplay ? '[direction:ltr]' : '[direction:rtl]'
)}
title={item.path}
>
{item.path}
Expand Down
23 changes: 17 additions & 6 deletions src/renderer/components/layout/TreeSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
TempWorkspaceItem,
WorktreeCreateOptions,
} from '@shared/types';
import { getPathBasename, isWslUncPath, trimTrailingPathSeparators } from '@shared/utils/path';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import {
ChevronRight,
Expand Down Expand Up @@ -454,7 +455,7 @@ export function TreeSidebar({
e.dataTransfer.setData('text/plain', `worktree:${index}`);

const dragImage = document.createElement('div');
dragImage.textContent = worktree.branch || worktree.path.split(/[\\/]/).pop() || '';
dragImage.textContent = worktree.branch || getPathBasename(worktree.path);
dragImage.style.cssText = `
position: fixed;
top: -9999px;
Expand Down Expand Up @@ -657,6 +658,8 @@ export function TreeSidebar({
const repoWts = worktreesMap[repo.path] || [];
const repoMainWorktree = repoWts.find((wt) => wt.isMainWorktree);
const workdir = repoMainWorktree?.path || repo.path;
const displayRepoPath = trimTrailingPathSeparators(repo.path);
const useLtrPathDisplay = isWslUncPath(repo.path);

return (
<div key={repo.path} className={cn('relative rounded-lg', isSelected && 'pb-2')}>
Expand Down Expand Up @@ -780,10 +783,13 @@ export function TreeSidebar({

{/* Row 3: Path */}
<span
className="relative z-10 pl-6 overflow-hidden whitespace-nowrap text-ellipsis text-xs text-muted-foreground [direction:rtl] [text-align:left]"
title={repo.path}
className={cn(
'relative z-10 pl-6 overflow-hidden whitespace-nowrap text-ellipsis text-xs text-muted-foreground [text-align:left]',
useLtrPathDisplay ? '[direction:ltr]' : '[direction:rtl]'
)}
title={displayRepoPath}
>
{repo.path}
{displayRepoPath}
</span>
</div>
{/* Drop indicator - bottom */}
Expand Down Expand Up @@ -1002,7 +1008,12 @@ export function TreeSidebar({
)}
</div>
{tempBasePath && (
<span className="relative z-10 pl-6 overflow-hidden whitespace-nowrap text-ellipsis text-xs text-muted-foreground [direction:rtl] [text-align:left] [unicode-bidi:plaintext]">
<span
className={cn(
'relative z-10 pl-6 overflow-hidden whitespace-nowrap text-ellipsis text-xs text-muted-foreground [text-align:left] [unicode-bidi:plaintext]',
isWslUncPath(tempBasePath) ? '[direction:ltr]' : '[direction:rtl]'
)}
>
{tempBasePath}
</span>
)}
Expand Down Expand Up @@ -1398,7 +1409,7 @@ export function TreeSidebar({
open={createWorktreeDialogOpen}
onOpenChange={setCreateWorktreeDialogOpen}
branches={branches}
projectName={selectedRepo?.split('/').pop() || ''}
projectName={selectedRepo ? getPathBasename(selectedRepo) : ''}
workdir={workdir}
isLoading={isCreating}
onSubmit={async (options) => {
Expand Down
Loading
Loading