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
This commit is contained in:
James Gatz
2025-07-24 11:23:24 +02:00
committed by GitHub
parent 17540a940d
commit 2dae9ef76f
15 changed files with 459 additions and 104 deletions

View File

@@ -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<typeof getGitBranches>[0]) => getGitBranches(options));
ipcMainHandle('git.fetchGitRemoteBranches', (_, options: Parameters<typeof fetchGitRemoteBranches>[0]) =>
fetchGitRemoteBranches(options),
);
ipcMainHandle('git.gitFetchAction', (_, options: Parameters<typeof gitFetchAction>[0]) => gitFetchAction(options));
ipcMainHandle('git.gitLogLoader', (_, options: Parameters<typeof gitLogLoader>[0]) => gitLogLoader(options));
ipcMainHandle('git.gitChangesLoader', (_, options: Parameters<typeof gitChangesLoader>[0]) =>

View File

@@ -49,6 +49,7 @@ export type HandleChannels =
| 'secretStorage.decryptString'
| 'git.loadGitRepository'
| 'git.getGitBranches'
| 'git.fetchGitRemoteBranches'
| 'git.gitFetchAction'
| 'git.gitLogLoader'
| 'git.gitChangesLoader'

View File

@@ -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),

View File

@@ -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();

View File

@@ -8,14 +8,16 @@ import { gitCallbacks } from './utils';
interface Options {
fsClient: git.FsClient;
gitRepository: Pick<GitRepository, 'credentials' | 'uri'>;
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,

View File

@@ -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<GitRepository> | null;
@@ -15,13 +16,19 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ gi
const linkIcon = <i className="fa fa-external-link-square" />;
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 (
<form
@@ -49,6 +56,7 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ 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<Props> = ({ 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]"
/>
</TextField>
@@ -104,12 +113,21 @@ export const CustomRepositorySettingsFormGroup: FunctionComponent<Props> = ({ gi
<Input
type="password"
disabled={Boolean(uri)}
defaultValue={'token' in credentials ? credentials?.token : ''}
onChange={e => 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]"
/>
</TextField>
</div>
<GitRemoteBranchSelect
credentials={{
password: credentials.password,
username: credentials.username,
}}
url={uri || ''}
isDisabled={Boolean(uri)}
/>
</form>
);
};

View File

@@ -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 (
<Label className="flex flex-col">
<span className="text-sm font-semibold">Branch</span>
<div className="flex items-center gap-2">
<ComboBox
key={`${url}:${remoteBranches[0]}:branch-select`}
aria-label="Branch to clone"
allowsCustomValue={false}
className="w-full"
defaultSelectedKey={remoteBranches[0]}
isDisabled={isComboboxDisabled}
items={remoteBranches.map(branch => ({
id: branch,
name: branch,
}))}
>
<div className="group flex items-center gap-2 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]">
<Input
name="branch"
aria-label="Search branches"
placeholder={isLoadingRemoteBranches ? 'Fetching remote branches...' : 'Default branch'}
className="w-full py-1 pl-2 pr-7 placeholder:italic"
/>
<Button
type="button"
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm !border-none text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
>
<Icon icon="caret-down" className="w-5 flex-shrink-0" />
</Button>
</div>
<Popover
className="grid w-[--trigger-width] min-w-max select-none grid-flow-col divide-x divide-solid divide-[--hl-md] overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] text-sm shadow-lg focus:outline-none"
placement="bottom start"
offset={8}
>
<ListBox<{
id: string;
name: string;
}> className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none">
{item => (
<ListBoxItem
textValue={item.name}
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap rounded bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] aria-selected:font-bold data-[focused]:bg-[--hl-xs]"
>
<span className="truncate">{item.name}</span>
</ListBoxItem>
)}
</ListBox>
</Popover>
</ComboBox>
<Button
type="button"
isDisabled={isComboboxDisabled}
className="m-2 flex aspect-square size-[--line-height-xs] items-center justify-center gap-2 truncate rounded-sm border border-solid border-[--hl-sm] p-2 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] disabled:opacity-30 aria-pressed:bg-[--hl-sm]"
aria-label="Refresh repositories"
onPress={() => {
if (uri && remoteBranchesFetcher.state === 'idle') {
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`,
},
);
}
}}
>
<Icon icon="refresh" className={isLoadingRemoteBranches ? 'animate-spin' : ''} />
</Button>
</div>
</Label>
);
};

View File

@@ -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<ReturnType<typeof window.main.git.getGitHubRepositories>>['repos'][number];
@@ -47,94 +47,99 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st
return (
<div className="flex flex-col">
<span className="flex gap-1 text-sm font-semibold">Repository</span>
{uri && (
<div className="form-control form-control--outlined">
<input className="form-control" disabled defaultValue={uri} />
</div>
)}
{!uri && (
<>
<div className="flex flex-row items-center gap-2 py-2">
<ComboBox
aria-label="Repositories"
allowsCustomValue={false}
className="w-full"
isDisabled={loading}
defaultItems={repositories.map(repo => ({
id: repo.clone_url,
name: repo.full_name,
}))}
onSelectionChange={key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null)}
>
<div className="group flex items-center gap-2 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]">
<Input
aria-label="Repository Search"
placeholder={loading ? 'Fetching...' : 'Find a repository...'}
className="w-full py-1 pl-2 pr-7 placeholder:italic"
/>
<ComboButton
id="github_repo_select_dropdown_button"
type="button"
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm !border-none text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
>
<Icon icon="caret-down" className="w-5 flex-shrink-0" />
</ComboButton>
</div>
<Popover
className="grid w-[--trigger-width] min-w-max select-none grid-flow-col divide-x divide-solid divide-[--hl-md] overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] text-sm shadow-lg focus:outline-none"
placement="bottom start"
offset={8}
>
<ListBox<{
id: string;
name: string;
}> className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none">
{item => (
<ListBoxItem
textValue={item.name}
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap rounded bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] aria-selected:font-bold data-[focused]:bg-[--hl-xs]"
>
<span className="truncate">{item.name}</span>
</ListBoxItem>
)}
</ListBox>
</Popover>
<input type="hidden" name="uri" value={selectedRepository?.clone_url || uri || ''} />
</ComboBox>
<Button
type="button"
disabled={loading}
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm border border-solid border-[--hl-sm] !p-0 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
aria-label="Refresh repositories"
onClick={() => {
setLoading(true);
getRepositories();
}}
>
<Icon icon="refresh" className={loading ? 'animate-spin' : ''} />
</Button>
</div>
{errors.length > 0 && (
<div className="notice error margin-bottom-sm">
{errors.map(error => (
<p key={error}>{error}</p>
))}
</div>
)}
{isGitHubAppUserToken(token) && (
<div className={`flex gap-1 text-sm ${loading ? 'opacity-40' : ''}`}>
Can't find a repository?
<Label className="flex flex-col">
<div className="flex items-center gap-2">
<span className="flex-1 text-sm font-semibold">Repository</span>
{!uri && isGitHubAppUserToken(token) && (
<div className={`flex items-center gap-1 text-sm ${loading ? 'opacity-40' : ''}`}>
<Icon icon="info-circle" className="text-[--hl]" />
<span>Can't find a repository?</span>
<a
className="flex items-center gap-1 text-purple-500"
className="flex items-center gap-1 text-[--color-surprise]"
href={`${getAppWebsiteBaseURL()}/oauth/github-app`}
>
Configure the App <i className="fa-solid fa-up-right-from-square" />
</a>
</div>
)}
</>
)}
</div>
{uri && (
<div className="form-control form-control--outlined">
<input className="form-control" disabled defaultValue={uri} />
</div>
)}
{!uri && (
<>
<div className="flex flex-row items-center gap-2">
<ComboBox
aria-label="Repositories"
allowsCustomValue={false}
className="w-full"
isDisabled={loading}
defaultItems={repositories.map(repo => ({
id: repo.clone_url,
name: repo.full_name,
}))}
onSelectionChange={key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null)}
>
<div className="group flex items-center gap-2 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] transition-colors focus:outline-none focus:ring-1 focus:ring-[--hl-md]">
<Input
aria-label="Repository Search"
placeholder={loading ? 'Fetching...' : 'Find a repository...'}
className="w-full py-1 pl-2 pr-7 placeholder:italic"
/>
<Button
id="github_repo_select_dropdown_button"
type="button"
className="m-2 flex aspect-square items-center justify-center gap-2 truncate rounded-sm !border-none text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
>
<Icon icon="caret-down" className="w-5 flex-shrink-0" />
</Button>
</div>
<Popover
className="grid w-[--trigger-width] min-w-max select-none grid-flow-col divide-x divide-solid divide-[--hl-md] overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] text-sm shadow-lg focus:outline-none"
placement="bottom start"
offset={8}
>
<ListBox<{
id: string;
name: string;
}> className="flex min-w-max select-none flex-col p-2 text-sm focus:outline-none">
{item => (
<ListBoxItem
textValue={item.name}
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap rounded bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] aria-selected:font-bold data-[focused]:bg-[--hl-xs]"
>
<span className="truncate">{item.name}</span>
</ListBoxItem>
)}
</ListBox>
</Popover>
<input type="hidden" name="uri" value={selectedRepository?.clone_url || uri || ''} />
</ComboBox>
<Button
type="button"
isDisabled={loading}
className="m-2 flex aspect-square size-[--line-height-xs] items-center justify-center gap-2 truncate rounded-sm border border-solid border-[--hl-sm] p-2 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
aria-label="Refresh repositories"
onPress={() => {
setLoading(true);
getRepositories();
}}
>
<Icon icon="refresh" className={loading ? 'animate-spin' : ''} />
</Button>
</div>
{errors.length > 0 && (
<div className="notice error margin-bottom-sm">
{errors.map(error => (
<p key={error}>{error}</p>
))}
</div>
)}
</>
)}
</Label>
{cannotFindRepository && (
<div className="text-sm text-red-500">
<Icon icon="warning" /> Repository information could not be retrieved. Please <code>Reset</code> and select a
@@ -146,6 +151,18 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st
<Icon icon="warning" /> You do not have write access to this repository
</div>
)}
{!uri && (
<GitRemoteBranchSelect
credentials={{
oauth2format: 'github',
token: '',
password: '',
username: '',
}}
isDisabled={loading}
url={selectedRepository?.clone_url || ''}
/>
)}
</div>
);
};

View File

@@ -61,7 +61,7 @@ const Avatar = ({ src }: { src: string }) => {
interface GitHubRepositoryFormProps {
uri?: string;
onSubmit: (args: Partial<GitRepository>) => void;
onSubmit: (args: Partial<GitRepository & { ref?: string }>) => 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: '',

View File

@@ -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
<Input
type="url"
defaultValue={uri}
onChange={e => 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]"
/>
</TextField>
<GitRemoteBranchSelect
credentials={{
oauth2format: 'gitlab',
token: '',
password: '',
username: '',
}}
url={gitlabUri || ''}
isDisabled={Boolean(uri)}
/>
{error && (
<p className="notice error margin-bottom-sm">
<button className="pull-right icon" onClick={() => setError('')}>

View File

@@ -97,6 +97,7 @@ export const ProjectSettingsForm: FC<Props> = ({
authorName?: string;
authorEmail?: string;
uri?: string;
ref?: string;
username?: string;
password?: string;
token?: string;
@@ -144,7 +145,7 @@ export const ProjectSettingsForm: FC<Props> = ({
}
}, [storageRules, project]);
const onGitRepoFormSubmit = (gitRepositoryPatch: Partial<GitRepository>) => {
const onGitRepoFormSubmit = (gitRepositoryPatch: Partial<GitRepository & { ref?: string }>) => {
const { author, credentials, created, modified, isPrivate, needsFullClone, uriNeedsMigration, ...repoPatch } =
gitRepositoryPatch;
@@ -154,6 +155,7 @@ export const ProjectSettingsForm: FC<Props> = ({
authorName: author?.name || '',
authorEmail: author?.email || '',
uri: repoPatch.uri,
ref: repoPatch.ref,
});
initCloneGitRepositoryFetcher.submit(

View File

@@ -259,6 +259,11 @@ async function renderApp() {
action: async (...args) =>
(await import('./routes/$organizationId.git')).initGitCloneAction(...args),
},
{
path: 'remote-branches',
action: async (...args) =>
(await import('./routes/$organizationId.git')).fetchRemoteBranchesAction(...args),
},
{
path: 'clone',
action: async (...args) =>

View File

@@ -1,6 +1,7 @@
import { type ActionFunction, redirect } from 'react-router';
import type { WorkspaceScope } from '../../models/workspace';
import type { GitCredentials } from '../../sync/git/git-vcs';
import { invariant } from '../../utils/invariant';
export type InitGitCloneResult =
@@ -78,3 +79,14 @@ export const cloneGitRepoAction: ActionFunction = async ({ request, params }): P
return redirect(`/organization/${organizationId}/project/${projectId}`);
};
export const fetchRemoteBranchesAction: ActionFunction = async ({ request }) => {
const data = (await request.json()) as {
uri: string;
credentials: GitCredentials;
};
const { uri, credentials } = data;
return window.main.git.fetchGitRemoteBranches({ uri, credentials });
};

View File

@@ -1,4 +1,4 @@
import { type ActionFunction, type LoaderFunction } from 'react-router';
import { type ActionFunction, type LoaderFunction, redirect } from 'react-router';
import * as models from '../../models';
import type { GitRepository } from '../../models/git-repository';
@@ -105,6 +105,84 @@ export const canPushLoader: LoaderFunction = async ({ params }): Promise<GitCanP
};
// Actions
export type InitGitCloneResult =
| {
files: {
scope: WorkspaceScope;
name: string;
path: string;
}[];
}
| {
errors: string[];
};
export const initGitCloneAction: ActionFunction = async ({ request, params }) => {
const { organizationId } = params;
invariant(organizationId, 'Organization ID is required');
const formData = await request.formData();
const data = Object.fromEntries(formData.entries()) as {
authorEmail: string;
authorName: string;
token: string;
uri: string;
username: string;
oauth2format: string;
ref?: string;
};
const initCloneResult = await window.main.git.initGitRepoClone({
organizationId,
...data,
});
if ('errors' in initCloneResult) {
return { errors: initCloneResult.errors };
}
return {
files: initCloneResult.files,
};
};
type CloneGitActionResult =
| Response
| {
errors?: string[];
};
export const cloneGitRepoAction: ActionFunction = async ({ request, params }): Promise<CloneGitActionResult> => {
const { organizationId } = params;
invariant(organizationId, 'Organization ID is required');
const formData = await request.formData();
const data = Object.fromEntries(formData.entries()) as {
authorEmail: string;
authorName: string;
token: string;
uri: string;
username: string;
oauth2format: string;
ref?: string;
};
const { errors, projectId } = await window.main.git.cloneGitRepo({
organizationId,
...data,
});
if (errors) {
return { errors };
}
invariant(organizationId, 'Organization ID is required');
invariant(projectId, 'Project ID is required');
return redirect(`/organization/${organizationId}/project/${projectId}`);
};
export const updateGitRepoAction: ActionFunction = async ({ request, params }) => {
const { projectId } = params;
invariant(projectId, 'Project ID is required');
@@ -117,6 +195,7 @@ export const updateGitRepoAction: ActionFunction = async ({ request, params }) =
uri: string;
username: string;
oauth2format: string;
ref?: string;
};
return window.main.git.updateGitRepo({

View File

@@ -187,6 +187,7 @@ export const updateGitRepoAction: ActionFunction = async ({ request, params }) =
uri: string;
username: string;
oauth2format: string;
ref?: string; // Optional ref for shallow clone
};
return window.main.git.updateGitRepo({