From 12115d571c7eaacd85d2b0ea7c9820012800b1ed Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Wed, 15 Jul 2026 08:37:47 +0100 Subject: [PATCH] feat(git): pull worktrees from origin --- e2e/file-explorer-sidebar.spec.ts | 5 +++ electron/fileViewer/gitDiffService.ts | 12 +++++++ electron/main.ts | 4 +++ electron/preload.ts | 2 ++ scripts/git-worktree-status.test.mjs | 39 ++++++++++++++++++++- src/App.tsx | 15 ++++++++ src/components/git-panel/WorktreesPanel.tsx | 16 +++++++++ src/types/terminay.ts | 1 + 8 files changed, 93 insertions(+), 1 deletion(-) diff --git a/e2e/file-explorer-sidebar.spec.ts b/e2e/file-explorer-sidebar.spec.ts index 83d6d59..71bf6e2 100644 --- a/e2e/file-explorer-sidebar.spec.ts +++ b/e2e/file-explorer-sidebar.spec.ts @@ -326,6 +326,11 @@ test('git sidebar pane shows no changes for a clean repository', async ({ create await expect(worktree.locator('.worktrees-panel__worktree-name')).toContainText('git-pane-clean', { timeout: 6000, }) + + await worktree.locator('.worktrees-panel__worktree-header').click({ button: 'right' }) + await expect(contextMenuItem(mainWindow, 'Pull from origin')).toBeVisible() + await mainWindow.keyboard.press('Escape') + await worktree.locator('.worktrees-panel__worktree-toggle').click() await expect(worktree.locator('.git-panel__message')).toHaveText('No changes', { timeout: 6000 }) diff --git a/electron/fileViewer/gitDiffService.ts b/electron/fileViewer/gitDiffService.ts index d7b259f..2a7e380 100644 --- a/electron/fileViewer/gitDiffService.ts +++ b/electron/fileViewer/gitDiffService.ts @@ -276,6 +276,18 @@ export class GitDiffService { } } + async pullWorktreeFromOrigin(rawWorktreePath: string): Promise { + const cwd = await this.resolveGitCommandCwd(rawWorktreePath) + const { stdout } = await execFileAsync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd }) + const branch = stdout.trim() + + if (!branch) { + throw new Error('Cannot pull into a detached HEAD worktree.') + } + + await execFileAsync('git', ['pull', 'origin', branch], { cwd }) + } + private async resolveGitCommandCwd(rawPath: string): Promise { const info = await this.fileBufferService.getFileInfo(rawPath) return getGitWorkingDirectory(info.path, info.isDirectory) diff --git a/electron/main.ts b/electron/main.ts index 5ca79fa..9b3dc97 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2669,6 +2669,10 @@ ipcMain.handle( }, ) +ipcMain.handle('fs:pull-git-worktree-from-origin', async (_event, payload: { worktreePath: string }) => { + await gitDiffService.pullWorktreeFromOrigin(payload.worktreePath) +}) + ipcMain.handle('fs:rename', async (_event, { oldPath, newPath }: { oldPath: string; newPath: string }) => { await rename(oldPath, newPath) }) diff --git a/electron/preload.ts b/electron/preload.ts index 6b7802b..a532ddf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -84,6 +84,8 @@ contextBridge.exposeInMainWorld('terminay', { ipcRenderer.invoke('fs:move-git-worktree', payload) as Promise, removeGitWorktree: (payload: { force?: boolean; repoPath: string; worktreePath: string }) => ipcRenderer.invoke('fs:remove-git-worktree', payload) as Promise, + pullGitWorktreeFromOrigin: (worktreePath: string) => + ipcRenderer.invoke('fs:pull-git-worktree-from-origin', { worktreePath }) as Promise, getFileInfo: (filePath: string) => ipcRenderer.invoke('file:get-info', { path: filePath }) as Promise, readFileBytes: (options: { path: string; start: number; length: number }) => ipcRenderer.invoke('file:read-bytes', options) as Promise, diff --git a/scripts/git-worktree-status.test.mjs b/scripts/git-worktree-status.test.mjs index e3584c2..a594bed 100644 --- a/scripts/git-worktree-status.test.mjs +++ b/scripts/git-worktree-status.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { promisify } from 'node:util' @@ -76,6 +76,43 @@ test('worktree status treats squash-merged commits as clean', async () => { } }) +test('pulling a worktree from origin fast-forwards its current branch', async () => { + const { GitDiffService } = await importBundled('../electron/fileViewer/gitDiffService.ts') + const root = await mkdtemp(join(tmpdir(), 'terminay-git-worktree-pull-test-')) + const remote = join(root, 'remote.git') + const project = join(root, 'project') + const contributor = join(root, 'contributor') + + try { + await git(['init', '--bare', remote], root) + await mkdir(project) + await git(['init', '-b', 'main'], project) + await git(['config', 'user.email', 'test@example.invalid'], project) + await git(['config', 'user.name', 'Terminay Test'], project) + await writeFile(join(project, 'shared.txt'), 'base\n') + await git(['add', 'shared.txt'], project) + await git(['commit', '-m', 'initial commit'], project) + await git(['remote', 'add', 'origin', remote], project) + await git(['push', '-u', 'origin', 'main'], project) + await git(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote) + + await git(['clone', remote, contributor], root) + await git(['config', 'user.email', 'contributor@example.invalid'], contributor) + await git(['config', 'user.name', 'Contributor'], contributor) + await writeFile(join(contributor, 'shared.txt'), 'updated upstream\n') + await git(['add', 'shared.txt'], contributor) + await git(['commit', '-m', 'upstream change'], contributor) + await git(['push', 'origin', 'main'], contributor) + + const service = new GitDiffService(fileBufferStub) + await service.pullWorktreeFromOrigin(project) + + assert.equal(await readFile(join(project, 'shared.txt'), 'utf8'), 'updated upstream\n') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + const fileBufferStub = { async getFileInfo(rawPath) { const normalizedPath = resolve(rawPath) diff --git a/src/App.tsx b/src/App.tsx index 3fe564d..cd9f597 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3767,6 +3767,20 @@ const ProjectWorkspace = forwardRef< [loadDirectory, project.rootFolder, refreshGitStatuses], ); + const handlePullWorktreeFromOrigin = useCallback( + async (worktree: GitWorktreeStatus) => { + try { + await window.terminay.pullGitWorktreeFromOrigin(worktree.path); + setErrorText(null); + } catch (error) { + setErrorText(`Failed to pull from origin: ${String(error)}`); + } finally { + refreshFileExplorerTree(); + } + }, + [refreshFileExplorerTree], + ); + const handleRevealWorktree = useCallback((worktree: GitWorktreeStatus) => { void window.terminay.revealInOS(worktree.path); }, []); @@ -7148,6 +7162,7 @@ const ProjectWorkspace = forwardRef< onOpenPushMenu={handleOpenWorktreePushMenu} onOpenTerminal={handleOpenTerminalAtWorktree} onOpenTerminalAtPath={handleOpenTerminalAt} + onPullFromOrigin={handlePullWorktreeFromOrigin} onRenameWorktree={handleRenameWorktree} onRevealWorktree={handleRevealWorktree} onSwitchProjectRoot={handleSwitchProjectRootToWorktree} diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index b6cb29d..c7c8302 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -1,6 +1,7 @@ import { ChevronDown, Copy, + Download, FileEdit, FolderInput, FolderOpen, @@ -33,6 +34,7 @@ export type WorktreesPanelProps = { ) => void; onOpenTerminal: (worktree: GitWorktreeStatus) => void; onOpenTerminalAtPath: (path: string) => void; + onPullFromOrigin: (worktree: GitWorktreeStatus) => void; onRenameWorktree: (worktree: GitWorktreeStatus) => void; onRevealWorktree: (worktree: GitWorktreeStatus) => void; onSwitchProjectRoot: (worktree: GitWorktreeStatus) => void; @@ -97,6 +99,7 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { onOpenPushMenu, onOpenTerminal, onOpenTerminalAtPath, + onPullFromOrigin, onRenameWorktree, onRevealWorktree, onSwitchProjectRoot, @@ -343,6 +346,7 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { items={buildWorktreeContextMenuItems({ onDeleteWorktree, onOpenTerminal, + onPullFromOrigin, onRenameWorktree, onRevealWorktree, onSwitchProjectRoot, @@ -358,6 +362,7 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { function buildWorktreeContextMenuItems(options: { onDeleteWorktree: (worktree: GitWorktreeStatus) => void; onOpenTerminal: (worktree: GitWorktreeStatus) => void; + onPullFromOrigin: (worktree: GitWorktreeStatus) => void; onRenameWorktree: (worktree: GitWorktreeStatus) => void; onRevealWorktree: (worktree: GitWorktreeStatus) => void; onSwitchProjectRoot: (worktree: GitWorktreeStatus) => void; @@ -367,6 +372,7 @@ function buildWorktreeContextMenuItems(options: { const { onDeleteWorktree, onOpenTerminal, + onPullFromOrigin, onRenameWorktree, onRevealWorktree, onSwitchProjectRoot, @@ -378,6 +384,16 @@ function buildWorktreeContextMenuItems(options: { worktree.isCurrent || worktree.isMain || worktree.isBare; return [ + { + label: 'Pull from origin', + icon: , + disabled: + unavailable || + worktree.isDetached || + !worktree.branch || + !!worktree.errorMessage, + onClick: () => onPullFromOrigin(worktree), + }, { label: 'Switch project root', icon: , diff --git a/src/types/terminay.ts b/src/types/terminay.ts index a7b87ec..803612a 100644 --- a/src/types/terminay.ts +++ b/src/types/terminay.ts @@ -599,6 +599,7 @@ export interface TerminayApi { getWorktreePanelStatus: (dirPath: string) => Promise moveGitWorktree: (payload: { repoPath: string; worktreePath: string; newPath: string }) => Promise removeGitWorktree: (payload: { force?: boolean; repoPath: string; worktreePath: string }) => Promise + pullGitWorktreeFromOrigin: (worktreePath: string) => Promise getFileInfo: (filePath: string) => Promise readFileBytes: (options: { path: string; start: number; length: number }) => Promise readFileText: (options: {