From 2dae9ef76fc3caa5ae76a6b7b0e1c0c47e42e705 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 24 Jul 2025 11:23:24 +0200 Subject: [PATCH] feat(Git Sync): Introduce the option to select a branch when cloning a repository (#8883) * Allow users to pick a branch when cloning a repo * save * cleanup * add ref option to git clone action * cleanup UI * fix ts in credentials * fix ts issues from rebase * fix lint issue * add ref option to git actions and update credentials handling in UI components * fix: include credentials in dependency array for useEffect in GitRemoteBranchSelect --- packages/insomnia/src/main/git-service.ts | 46 ++++- packages/insomnia/src/main/ipc/electron.ts | 1 + packages/insomnia/src/preload.ts | 1 + packages/insomnia/src/sync/git/git-vcs.ts | 58 +++++- .../insomnia/src/sync/git/shallow-clone.ts | 4 +- .../custom-repository-settings-form.tsx | 26 ++- .../git-remote-branch-select.tsx | 121 ++++++++++++ .../github-repository-select.tsx | 183 ++++++++++-------- .../github-repository-settings-form.tsx | 4 +- .../gitlab-repository-settings-form.tsx | 16 +- .../project/project-settings-form.tsx | 4 +- packages/insomnia/src/ui/index.tsx | 5 + .../src/ui/routes/$organizationId.git.tsx | 12 ++ ...$organizationId.project.$projectId.git.tsx | 81 +++++++- ....$projectId.workspace.$workspaceId.git.tsx | 1 + 15 files changed, 459 insertions(+), 104 deletions(-) create mode 100644 packages/insomnia/src/ui/components/git-credentials/git-remote-branch-select.tsx diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 41ef4fb06f..153871c233 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -26,10 +26,12 @@ import type { GitRepository } from '../models/git-repository'; import { isWorkspace, type WorkspaceScope, WorkspaceScopeKeys } from '../models/workspace'; import { fsClient } from '../sync/git/fs-client'; import GitVCS, { + fetchRemoteBranches, GIT_CLONE_DIR, GIT_INSOMNIA_DIR, GIT_INSOMNIA_DIR_NAME, GIT_INTERNAL_DIR, + type GitCredentials, MergeConflictError, } from '../sync/git/git-vcs'; import { MemClient } from '../sync/git/mem-client'; @@ -189,6 +191,7 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: try { const gitRepository = await getGitRepository({ workspaceId, projectId }); + const bufferId = await database.bufferChanges(); const fsClient = await getGitFSClient({ gitRepositoryId: gitRepository._id, projectId, workspaceId }); if (GitVCS.isInitializedForRepo(gitRepository._id) && !gitRepository.needsFullClone) { @@ -241,6 +244,8 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: legacyInsomniaWorkspace = await containsLegacyInsomniaDir({ fsClient }); } + await database.flushChanges(bufferId); + return { branch: await GitVCS.getCurrentBranch(), branches: await GitVCS.listBranches(), @@ -591,6 +596,7 @@ export const initGitRepoCloneAction = async ({ token, username, oauth2format, + ref, }: { organizationId: string; uri: string; @@ -599,6 +605,7 @@ export const initGitRepoCloneAction = async ({ token: string; username: string; oauth2format?: string; + ref?: string; }): Promise< | { files: { @@ -647,6 +654,7 @@ export const initGitRepoCloneAction = async ({ try { await shallowClone({ + ref, fsClient: inMemoryFsClient, gitRepository: repoSettingsPatch as GitRepository, }); @@ -701,6 +709,7 @@ export const cloneGitRepoAction = async ({ token, username, oauth2format, + ref, }: { organizationId: string; projectId?: string; @@ -712,6 +721,7 @@ export const cloneGitRepoAction = async ({ token: string; username: string; oauth2format?: string; + ref?: string; }) => { try { if (!projectId) { @@ -750,6 +760,7 @@ export const cloneGitRepoAction = async ({ try { await shallowClone({ + ref, fsClient: inMemoryFsClient, gitRepository: repoSettingsPatch as GitRepository, }); @@ -822,6 +833,7 @@ export const cloneGitRepoAction = async ({ directory: GIT_CLONE_DIR, fs: fsClient, gitDirectory: GIT_INTERNAL_DIR, + ref, }); await models.gitRepository.update(gitRepository, { @@ -846,7 +858,10 @@ export const cloneGitRepoAction = async ({ await migrateLegacyInsomniaFolderToFile({ projectId: project._id }); } - await models.gitRepository.update(gitRepository, { + const updateRepository = await models.gitRepository.getById(gitRepository._id); + invariant(updateRepository, 'Git Repository not found'); + + await models.gitRepository.update(updateRepository, { cachedGitLastCommitTime: Date.now(), cachedGitRepositoryBranch: await GitVCS.getCurrentBranch(), }); @@ -900,6 +915,7 @@ export const cloneGitRepoAction = async ({ const providerName = getOauth2FormatName(repoSettingsPatch.credentials); try { await shallowClone({ + ref, fsClient: inMemoryFsClient, gitRepository: repoSettingsPatch as GitRepository, }); @@ -1089,6 +1105,7 @@ export const updateGitRepoAction = async ({ oauth2format, username, token, + ref, }: { projectId: string; workspaceId?: string; @@ -1098,6 +1115,7 @@ export const updateGitRepoAction = async ({ oauth2format?: string; username: string; token: string; + ref?: string; }) => { try { let gitRepositoryId: string | null | undefined = null; @@ -1178,6 +1196,7 @@ export const updateGitRepoAction = async ({ gitDirectory: GIT_INTERNAL_DIR, gitCredentials: gitRepository.credentials, legacyDiff: Boolean(workspaceId), + ref, }); await GitVCS.setAuthor(); @@ -1704,6 +1723,26 @@ export const pushToGitRemoteAction = async ({ }; }; +export async function fetchGitRemoteBranches({ + uri, + credentials, +}: { + uri: string; + credentials?: GitCredentials; +}): Promise<{ branches: string[]; errors?: string[] }> { + try { + const branches = await fetchRemoteBranches({ + uri: parseGitToHttpsURL(uri), + credentials, + }); + + return { branches }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Error while fetching remote branches'; + return { branches: [], errors: [errorMessage] }; + } +} + export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) { try { const gitRepository = await getGitRepository({ projectId, workspaceId }); @@ -2472,7 +2511,7 @@ export interface GitServiceAPI { diffFileLoader: typeof diffFileLoader; getRepositoryDirectoryTree: typeof getRepositoryDirectoryTree; migrateLegacyInsomniaFolderToFile: typeof migrateLegacyInsomniaFolderToFile; - + fetchGitRemoteBranches: typeof fetchGitRemoteBranches; initSignInToGitHub: typeof initSignInToGitHub; completeSignInToGitHub: typeof completeSignInToGitHub; signOutOfGitHub: typeof signOutOfGitHub; @@ -2489,6 +2528,9 @@ export const registerGitServiceAPI = () => { loadGitRepository(options), ); ipcMainHandle('git.getGitBranches', (_, options: Parameters[0]) => getGitBranches(options)); + ipcMainHandle('git.fetchGitRemoteBranches', (_, options: Parameters[0]) => + fetchGitRemoteBranches(options), + ); ipcMainHandle('git.gitFetchAction', (_, options: Parameters[0]) => gitFetchAction(options)); ipcMainHandle('git.gitLogLoader', (_, options: Parameters[0]) => gitLogLoader(options)); ipcMainHandle('git.gitChangesLoader', (_, options: Parameters[0]) => diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 1d05c950fa..d9076cc1e0 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -49,6 +49,7 @@ export type HandleChannels = | 'secretStorage.decryptString' | 'git.loadGitRepository' | 'git.getGitBranches' + | 'git.fetchGitRemoteBranches' | 'git.gitFetchAction' | 'git.gitLogLoader' | 'git.gitChangesLoader' diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index fe002cce7c..f3ed8efe6a 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -70,6 +70,7 @@ const secretStorage: secretStorageBridgeAPI = { const git: GitServiceAPI = { loadGitRepository: options => ipcRenderer.invoke('git.loadGitRepository', options), getGitBranches: options => ipcRenderer.invoke('git.getGitBranches', options), + fetchGitRemoteBranches: options => ipcRenderer.invoke('git.fetchGitRemoteBranches', options), gitFetchAction: options => ipcRenderer.invoke('git.gitFetchAction', options), gitLogLoader: options => ipcRenderer.invoke('git.gitLogLoader', options), gitChangesLoader: options => ipcRenderer.invoke('git.gitChangesLoader', options), diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 0343f5885b..d70e35699b 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -68,6 +68,7 @@ interface InitOptions { gitCredentials?: GitCredentials | null; uri?: string; repoId: string; + ref?: string; // If enabled git-vcs will only diff files inside a .insomnia directory legacyDiff?: boolean; } @@ -78,6 +79,7 @@ interface InitFromCloneOptions { directory: string; fs: git.FsClient; gitDirectory: string; + ref?: string; repoId: string; } @@ -102,7 +104,7 @@ function getInsomniaFileName(blob: void | Uint8Array | undefined): string { try { const parsed = parse(Buffer.from(blob).toString('utf-8')); return parsed?.fileName || parsed?.name || ''; - } catch (e) { + } catch { // If the document couldn't be parsed as yaml return an empty string return ''; } @@ -120,13 +122,14 @@ interface BaseOpts { uri: string; repoId: string; legacyDiff?: boolean; + ref?: string; } export class GitVCS { // @ts-expect-error -- TSCONVERSION not initialized with required properties _baseOpts: BaseOpts = gitCallbacks(); - async init({ directory, fs, gitDirectory, gitCredentials, uri = '', repoId, legacyDiff = false }: InitOptions) { + async init({ directory, fs, gitDirectory, gitCredentials, uri = '', repoId, legacyDiff = false, ref }: InitOptions) { this._baseOpts = { ...this._baseOpts, dir: directory, @@ -137,6 +140,7 @@ export class GitVCS { uri, repoId, legacyDiff, + ref, }; if (await this.repoExists()) { @@ -158,7 +162,7 @@ export class GitVCS { }); defaultBranch = mainRef?.target?.replace('refs/heads/', '') || 'main'; - } catch (err) { + } catch { // Ignore error } @@ -174,13 +178,13 @@ export class GitVCS { }); return remoteOriginURI; - } catch (err) { + } catch { // Ignore error return this._baseOpts.uri || ''; } } - async initFromClone({ repoId, url, gitCredentials, directory, fs, gitDirectory }: InitFromCloneOptions) { + async initFromClone({ repoId, url, gitCredentials, directory, fs, gitDirectory, ref }: InitFromCloneOptions) { this._baseOpts = { ...this._baseOpts, ...gitCallbacks(gitCredentials), @@ -190,11 +194,14 @@ export class GitVCS { http: httpClient, repoId, }; + + const initRef = ref || this._baseOpts.ref; + try { await git.clone({ ...this._baseOpts, url, - singleBranch: true, + ...(initRef ? { ref: initRef } : {}), }); } catch (err) { // If we there is a checkout conflict we only want to clone the repo @@ -202,11 +209,12 @@ export class GitVCS { await git.clone({ ...this._baseOpts, url, - singleBranch: true, + ...(initRef ? { ref: initRef } : {}), noCheckout: true, }); } } + console.log(`[git] Cloned repo to ${gitDirectory} from ${url}`); } @@ -366,7 +374,7 @@ export class GitVCS { try { return Buffer.from(blob).toString('utf-8'); - } catch (e) { + } catch { return null; } }); @@ -974,6 +982,7 @@ export class GitVCS { await git.checkout({ ...this._baseOpts, ref: branch, + remote: 'origin', }); const branches = await this.listBranches(); @@ -986,7 +995,7 @@ export class GitVCS { async repoExists() { try { await git.getConfig({ ...this._baseOpts, path: '' }); - } catch (err) { + } catch { return false; } @@ -1067,4 +1076,35 @@ function assertIsPromiseFsClient(fs: git.FsClient): asserts fs is git.PromiseFsC } } +export async function fetchRemoteBranches({ uri, credentials }: { uri: string; credentials?: GitCredentials | null }) { + const [mainRef] = await git.listServerRefs({ + ...gitCallbacks(credentials), + http: httpClient, + url: uri, + prefix: 'HEAD', + symrefs: true, + }); + + const remoteRefs = await git.listServerRefs({ + ...gitCallbacks(credentials), + http: httpClient, + url: uri, + prefix: 'refs/heads/', + symrefs: true, + }); + + const defaultBranch = mainRef?.target?.replace('refs/heads/', '') || 'main'; + + const remoteBranches = remoteRefs + .filter(b => b.ref !== 'HEAD') + .map(b => b.ref.replace('refs/heads/', '')) + .sort((a, b) => { + if (a === defaultBranch) return -1; + if (b === defaultBranch) return 1; + return a.localeCompare(b); + }); + + return remoteBranches; +} + export default new GitVCS(); diff --git a/packages/insomnia/src/sync/git/shallow-clone.ts b/packages/insomnia/src/sync/git/shallow-clone.ts index 4bf0030b42..07ae280de3 100644 --- a/packages/insomnia/src/sync/git/shallow-clone.ts +++ b/packages/insomnia/src/sync/git/shallow-clone.ts @@ -8,14 +8,16 @@ import { gitCallbacks } from './utils'; interface Options { fsClient: git.FsClient; gitRepository: Pick; + ref?: string; } /** * Create a shallow clone into the provided FS plugin. * */ -export const shallowClone = async ({ fsClient, gitRepository }: Options) => { +export const shallowClone = async ({ fsClient, gitRepository, ref = undefined }: Options) => { await git.clone({ ...gitCallbacks(gitRepository.credentials), + ...(ref ? { ref } : {}), fs: fsClient, http: httpClient, dir: GIT_CLONE_DIR, diff --git a/packages/insomnia/src/ui/components/git-credentials/custom-repository-settings-form.tsx b/packages/insomnia/src/ui/components/git-credentials/custom-repository-settings-form.tsx index 21536a5c44..d8f40be46a 100644 --- a/packages/insomnia/src/ui/components/git-credentials/custom-repository-settings-form.tsx +++ b/packages/insomnia/src/ui/components/git-credentials/custom-repository-settings-form.tsx @@ -5,6 +5,7 @@ import { docsGitAccessToken } from '../../../common/documentation'; import type { GitRepository } from '../../../models/git-repository'; import { Link } from '../base/link'; import { HelpTooltip } from '../help-tooltip'; +import { GitRemoteBranchSelect } from './git-remote-branch-select'; export interface Props { gitRepository?: Partial | null; @@ -15,13 +16,19 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent = ({ gi const linkIcon = ; const defaultValues = gitRepository || { uri: '', - credentials: { username: '', token: '' }, + credentials: { username: '', password: '' }, author: { name: '', email: '' }, }; - const uri = defaultValues.uri; + const [credentials, setCredentials] = React.useState({ + username: defaultValues.credentials?.username || '', + password: + defaultValues.credentials && 'password' in defaultValues.credentials ? defaultValues.credentials.password : '', + }); + + const [uri, setUri] = React.useState(defaultValues.uri || ''); + const author = defaultValues.author; - const credentials = defaultValues?.credentials || { username: '', token: '' }; return (
= ({ gi type="url" autoFocus defaultValue={uri} + onChange={e => setUri(e.currentTarget.value)} disabled={Boolean(uri)} placeholder="https://github.com/org/repo.git" className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]" @@ -81,6 +89,7 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent = ({ gi placeholder="MyUser" disabled={Boolean(uri)} defaultValue={credentials?.username} + onChange={e => setCredentials({ ...credentials, username: e.currentTarget.value })} className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]" /> @@ -104,12 +113,21 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent = ({ gi setCredentials({ ...credentials, password: e.currentTarget.value })} + defaultValue={'password' in credentials ? credentials?.password : ''} placeholder="88e7ee63b254e4b0bf047559eafe86ba9dd49507" className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]" /> + ); }; diff --git a/packages/insomnia/src/ui/components/git-credentials/git-remote-branch-select.tsx b/packages/insomnia/src/ui/components/git-credentials/git-remote-branch-select.tsx new file mode 100644 index 0000000000..82956f7835 --- /dev/null +++ b/packages/insomnia/src/ui/components/git-credentials/git-remote-branch-select.tsx @@ -0,0 +1,121 @@ +import React, { useDeferredValue, useEffect } from 'react'; +import { Button, ComboBox, Input, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'; +import { useFetcher, useParams } from 'react-router'; + +import type { GitCredentials } from '../../../sync/git/git-vcs'; +import { Icon } from '../icon'; + +export const GitRemoteBranchSelect = ({ + url, + isDisabled, + credentials, +}: { + url: string; + isDisabled: boolean; + credentials: GitCredentials; +}) => { + const remoteBranchesFetcher = useFetcher<{ branches: string[] }>({ key: url || 'branch-select' }); + const { organizationId } = useParams<{ organizationId: string }>(); + + const isLoadingRemoteBranches = remoteBranchesFetcher.state !== 'idle'; + const uri = useDeferredValue(url); + + useEffect(() => { + if (uri && remoteBranchesFetcher.state === 'idle' && !remoteBranchesFetcher.data) { + remoteBranchesFetcher.submit( + // @ts-expect-error credentials is not defined in the type, but it is used here + { + uri, + credentials, + }, + { + method: 'POST', + encType: 'application/json', + action: `/organization/${organizationId}/git/remote-branches`, + }, + ); + } + }, [organizationId, remoteBranchesFetcher, uri, credentials]); + + const remoteBranches = remoteBranchesFetcher.data?.branches || []; + + const isComboboxDisabled = remoteBranches.length === 0 || isLoadingRemoteBranches || !url || isDisabled; + + return ( + + ); +}; diff --git a/packages/insomnia/src/ui/components/git-credentials/github-repository-select.tsx b/packages/insomnia/src/ui/components/git-credentials/github-repository-select.tsx index 0ddc0db4eb..c95b3af8fd 100644 --- a/packages/insomnia/src/ui/components/git-credentials/github-repository-select.tsx +++ b/packages/insomnia/src/ui/components/git-credentials/github-repository-select.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { Button as ComboButton, ComboBox, Input, ListBox, ListBoxItem, Popover } from 'react-aria-components'; +import { Button, ComboBox, Input, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'; import { getAppWebsiteBaseURL } from '../../../common/constants'; import { isGitHubAppUserToken } from '../github-app-config-link'; import { Icon } from '../icon'; -import { Button } from '../themed-button'; +import { GitRemoteBranchSelect } from './git-remote-branch-select'; type GitHubRepository = Awaited>['repos'][number]; @@ -47,94 +47,99 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st return (
- Repository - {uri && ( -
- -
- )} - {!uri && ( - <> -
- ({ - id: repo.clone_url, - name: repo.full_name, - }))} - onSelectionChange={key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null)} - > -
- - - - -
- - className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none"> - {item => ( - - {item.name} - - )} - - - -
- -
- {errors.length > 0 && ( -
- {errors.map(error => ( -

{error}

- ))} -
- )} - {isGitHubAppUserToken(token) && ( -
- Can't find a repository? + {cannotFindRepository && (
Repository information could not be retrieved. Please Reset and select a @@ -146,6 +151,18 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st You do not have write access to this repository
)} + {!uri && ( + + )}
); }; diff --git a/packages/insomnia/src/ui/components/git-credentials/github-repository-settings-form.tsx b/packages/insomnia/src/ui/components/git-credentials/github-repository-settings-form.tsx index a35636016a..affe67b24a 100644 --- a/packages/insomnia/src/ui/components/git-credentials/github-repository-settings-form.tsx +++ b/packages/insomnia/src/ui/components/git-credentials/github-repository-settings-form.tsx @@ -61,7 +61,7 @@ const Avatar = ({ src }: { src: string }) => { interface GitHubRepositoryFormProps { uri?: string; - onSubmit: (args: Partial) => void; + onSubmit: (args: Partial) => void; credentials: GitCredentials; } @@ -77,12 +77,14 @@ const GitHubRepositoryForm = ({ uri, credentials, onSubmit }: GitHubRepositoryFo event.preventDefault(); const formData = new FormData(event.currentTarget); const uri = formData.get('uri') as string; + const ref = formData.get('branch') as string; if (!uri) { setError('Please select a repository'); return; } onSubmit({ uri, + ref, credentials: { oauth2format: 'github', password: '', diff --git a/packages/insomnia/src/ui/components/git-credentials/gitlab-repository-settings-form.tsx b/packages/insomnia/src/ui/components/git-credentials/gitlab-repository-settings-form.tsx index 3ce93bfd84..f551eac2dc 100644 --- a/packages/insomnia/src/ui/components/git-credentials/gitlab-repository-settings-form.tsx +++ b/packages/insomnia/src/ui/components/git-credentials/gitlab-repository-settings-form.tsx @@ -6,6 +6,7 @@ import type { GitCredentials } from '../../../models/git-credentials'; import type { GitRepository } from '../../../models/git-repository'; import { PromptButton } from '../base/prompt-button'; import { Icon } from '../icon'; +import { GitRemoteBranchSelect } from './git-remote-branch-select'; interface Props { uri?: string; @@ -67,7 +68,7 @@ interface GitLabRepositoryFormProps { const GitLabRepositoryForm = ({ uri, credentials, onSubmit }: GitLabRepositoryFormProps) => { const [error, setError] = useState(''); - + const [gitlabUri, setGitlabUri] = useState(uri || ''); const signOutFetcher = useFetcher(); return ( @@ -111,11 +112,22 @@ const GitLabRepositoryForm = ({ uri, credentials, onSubmit }: GitLabRepositoryFo setGitlabUri(e.currentTarget.value)} disabled={Boolean(uri)} - placeholder="https://github.com/org/repo.git" + placeholder="https://gitlab.com/org/repo.git" className="w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] py-1 pl-2 pr-7 text-[--color-font] transition-colors placeholder:text-sm placeholder:italic focus:outline-none focus:ring-1 focus:ring-[--hl-md]" /> + {error && (