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
1 change: 1 addition & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum IPC {
GetFileDiff = 'get_file_diff',
GetFileDiffFromBranch = 'get_file_diff_from_branch',
GetGitignoredDirs = 'get_gitignored_dirs',
ListImportableWorktrees = 'list_importable_worktrees',
GetWorktreeStatus = 'get_worktree_status',
CheckMergeStatus = 'check_merge_status',
MergeTask = 'merge_task',
Expand Down
104 changes: 104 additions & 0 deletions electron/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,58 @@ function parseConflictPath(line: string): string | null {
return candidate || null;
}

function safeRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}

interface ListedWorktree {
path: string;
branchName: string | null;
detached: boolean;
}

function parseWorktreeList(output: string): ListedWorktree[] {
const entries: ListedWorktree[] = [];
let current: ListedWorktree | null = null;

for (const rawLine of output.split('\n')) {
const line = rawLine.trimEnd();
if (!line) {
if (current?.path) entries.push(current);
current = null;
continue;
}

if (line.startsWith('worktree ')) {
if (current?.path) entries.push(current);
current = {
path: line.slice('worktree '.length).trim(),
branchName: null,
detached: false,
};
continue;
}

if (!current) continue;
if (line.startsWith('branch ')) {
const ref = line.slice('branch '.length).trim();
const prefix = 'refs/heads/';
current.branchName = ref.startsWith(prefix) ? ref.slice(prefix.length) : ref;
continue;
}
if (line === 'detached') {
current.detached = true;
}
}

if (current?.path) entries.push(current);
return entries;
}

async function computeBranchDiffStats(
projectRoot: string,
mainBranch: string,
Expand Down Expand Up @@ -696,6 +748,58 @@ export async function getWorktreeStatus(
};
}

export async function listImportableWorktrees(projectRoot: string): Promise<
Array<{
path: string;
branch_name: string;
has_committed_changes: boolean;
has_uncommitted_changes: boolean;
}>
> {
const projectRealPath = safeRealpath(projectRoot);
const { stdout } = await exec('git', ['worktree', 'list', '--porcelain'], {
cwd: projectRoot,
maxBuffer: MAX_BUFFER,
});

const candidates = parseWorktreeList(stdout).filter((entry) => {
if (!entry.path || !entry.branchName || entry.detached) return false;
return safeRealpath(entry.path) !== projectRealPath;
});

const results = await Promise.all(
candidates.map(async (entry) => {
try {
const status = await getWorktreeStatus(entry.path);
return {
path: entry.path,
branch_name: entry.branchName ?? '',
has_committed_changes: status.has_committed_changes,
has_uncommitted_changes: status.has_uncommitted_changes,
};
} catch {
return null;
}
}),
);

const filtered = results.filter(
(
entry,
): entry is {
path: string;
branch_name: string;
has_committed_changes: boolean;
has_uncommitted_changes: boolean;
} => entry !== null,
);

filtered.sort(
(a, b) => a.branch_name.localeCompare(b.branch_name) || a.path.localeCompare(b.path),
);
return filtered;
}

/** Stage all changes and commit in a worktree. */
export async function commitAll(worktreePath: string, message: string): Promise<void> {
await exec('git', ['add', '-A'], { cwd: worktreePath });
Expand Down
5 changes: 5 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
getFileDiff,
getFileDiffFromBranch,
getWorktreeStatus,
listImportableWorktrees,
commitAll,
discardUncommitted,
checkMergeStatus,
Expand Down Expand Up @@ -165,6 +166,10 @@ export function registerAllHandlers(win: BrowserWindow): void {
validatePath(args.projectRoot, 'projectRoot');
return getGitIgnoredDirs(args.projectRoot);
});
ipcMain.handle(IPC.ListImportableWorktrees, (_e, args) => {
validatePath(args.projectRoot, 'projectRoot');
return listImportableWorktrees(args.projectRoot);
});
ipcMain.handle(IPC.GetWorktreeStatus, (_e, args) => {
validatePath(args.worktreePath, 'worktreePath');
return getWorktreeStatus(args.worktreePath);
Expand Down
1 change: 1 addition & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const ALLOWED_CHANNELS = new Set([
'get_file_diff',
'get_file_diff_from_branch',
'get_gitignored_dirs',
'list_importable_worktrees',
'get_worktree_status',
'commit_all',
'discard_uncommitted',
Expand Down
69 changes: 40 additions & 29 deletions src/components/CloseTaskDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ interface CloseTaskDialogProps {

export function CloseTaskDialog(props: CloseTaskDialogProps) {
const [worktreeStatus] = createResource(
() => (props.open && !props.task.directMode ? props.task.worktreePath : null),
() =>
props.open && !props.task.directMode && !props.task.externalWorktree
? props.task.worktreePath
: null,
(path) => invoke<WorktreeStatus>(IPC.GetWorktreeStatus, { worktreePath: path }),
);

Expand All @@ -34,7 +37,9 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
<Show when={!props.task.directMode}>
<Show
when={
worktreeStatus()?.has_uncommitted_changes || worktreeStatus()?.has_committed_changes
!props.task.externalWorktree &&
(worktreeStatus()?.has_uncommitted_changes ||
worktreeStatus()?.has_committed_changes)
}
>
<div
Expand Down Expand Up @@ -79,45 +84,51 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
</Show>
{(() => {
const project = getProject(props.task.projectId);
const willDeleteBranch = project?.deleteBranchOnClose ?? true;
const willDeleteBranch = props.task.externalWorktree
? false
: (project?.deleteBranchOnClose ?? true);
return (
<>
<p style={{ margin: '0 0 8px' }}>
{willDeleteBranch
? 'This action cannot be undone. The following will be permanently deleted:'
: 'The worktree will be removed but the branch will be kept:'}
{props.task.externalWorktree
? 'This will stop all running agents and shells and remove the imported task from Parallel Code. The existing git worktree will be left untouched.'
: willDeleteBranch
? 'This action cannot be undone. The following will be permanently deleted:'
: 'The worktree will be removed but the branch will be kept:'}
</p>
<ul
style={{
margin: '0',
'padding-left': '20px',
display: 'flex',
'flex-direction': 'column',
gap: '4px',
}}
>
<Show when={willDeleteBranch}>
<Show when={!props.task.externalWorktree}>
<ul
style={{
margin: '0',
'padding-left': '20px',
display: 'flex',
'flex-direction': 'column',
gap: '4px',
}}
>
<Show when={willDeleteBranch}>
<li>
Local feature branch <strong>{props.task.branchName}</strong>
</li>
</Show>
<li>
Local feature branch <strong>{props.task.branchName}</strong>
</li>
</Show>
<li>
Worktree at <strong>{props.task.worktreePath}</strong>
</li>
<Show when={!willDeleteBranch}>
<li style={{ color: theme.fgMuted }}>
Branch <strong>{props.task.branchName}</strong> will be kept
Worktree at <strong>{props.task.worktreePath}</strong>
</li>
</Show>
</ul>
<Show when={!willDeleteBranch}>
<li style={{ color: theme.fgMuted }}>
Branch <strong>{props.task.branchName}</strong> will be kept
</li>
</Show>
</ul>
</Show>
</>
);
})()}
</Show>
</div>
}
confirmLabel={props.task.directMode ? 'Close' : 'Delete'}
danger={!props.task.directMode}
confirmLabel={props.task.directMode || props.task.externalWorktree ? 'Close' : 'Delete'}
danger={!props.task.directMode && !props.task.externalWorktree}
onConfirm={() => {
props.onDone();
closeTask(props.task.id);
Expand Down
23 changes: 23 additions & 0 deletions src/components/EditProjectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name';
import { theme } from '../lib/theme';
import type { Project, TerminalBookmark } from '../store/types';
import { ImportWorktreesDialog } from './ImportWorktreesDialog';

interface EditProjectDialogProps {
project: Project | null;
Expand All @@ -29,6 +30,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) {
const [defaultDirectMode, setDefaultDirectMode] = createSignal(false);
const [bookmarks, setBookmarks] = createSignal<TerminalBookmark[]>([]);
const [newCommand, setNewCommand] = createSignal('');
const [showImportDialog, setShowImportDialog] = createSignal(false);
let nameRef!: HTMLInputElement;

// Sync signals when project prop changes
Expand Down Expand Up @@ -120,6 +122,22 @@ export function EditProjectDialog(props: EditProjectDialogProps) {
>
{project().path}
</div>
<button
type="button"
onClick={() => setShowImportDialog(true)}
style={{
padding: '3px 10px',
background: theme.bgInput,
border: `1px solid ${theme.border}`,
'border-radius': '6px',
color: theme.fgMuted,
cursor: 'pointer',
'font-size': '11px',
'flex-shrink': '0',
}}
>
Import Worktrees
</button>
<button
type="button"
onClick={() => relinkProject(project().id)}
Expand Down Expand Up @@ -519,6 +537,11 @@ export function EditProjectDialog(props: EditProjectDialogProps) {
Save
</button>
</div>
<ImportWorktreesDialog
open={showImportDialog()}
project={project()}
onClose={() => setShowImportDialog(false)}
/>
</>
)}
</Show>
Expand Down
Loading