Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Git Sync): Support for multiple files on the same repository #7843

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
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
50 changes: 25 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const createGitRepository = async (workspaceId: string, repo: Partial<Git
const meta = await models.workspaceMeta.getOrCreateByParentId(workspaceId);
await models.workspaceMeta.update(meta, {
gitRepositoryId: newRepo._id,
// @TODO Get the main/master branch name here
cachedGitRepositoryBranch: 'main',
});
};

Expand Down
10 changes: 0 additions & 10 deletions packages/insomnia/src/sync/git/ne-db-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,6 @@ export class NeDBClient {
// When git is reading from NeDb, reset keys we wish to ignore to their original values
resetKeys(doc);

// It would be nice to be able to add this check here but we can't since
// isomorphic-git may have just deleted the workspace from the FS. This
// happens frequently during branch checkouts and merges
//
// if (doc.type !== models.workspace.type) {
// const ancestors = await db.withAncestors(doc);
// if (!ancestors.find(isWorkspace)) {
// throw new Error(`Not found under workspace ${filePath}`);
// }
// }
const raw = Buffer.from(YAML.stringify(doc), 'utf8');

if (options.encoding) {
Expand Down
34 changes: 24 additions & 10 deletions packages/insomnia/src/sync/git/routable-fs-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,37 @@ export function routableFSClient(
const execMethod = async (method: Methods, filePath: string, ...args: any[]) => {
filePath = path.normalize(filePath);

for (const prefix of Object.keys(otherFS)) {
if (filePath.indexOf(path.normalize(prefix)) === 0) {
// TODO: remove non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return otherFS[prefix].promises[method]!(filePath, ...args);
try {
for (const prefix of Object.keys(otherFS)) {
if (filePath.indexOf(path.normalize(prefix)) === 0) {
// TODO: remove non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = await otherFS[prefix].promises[method]!(filePath, ...args);

return result;
}
}
} catch (err) {
throw err;
}

// Uncomment this to debug operations
// console.log('[routablefs] Executing', method, filePath, { args });
console.log('[routablefs] Executing', method, filePath, { args });
// Fallback to default if no prefix matched
// TODO: remove non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = await defaultFS.promises[method]!(filePath, ...args);
// Uncomment this to debug operations
// console.log('[routablefs] Executing', method, filePath, { args }, { result });
return result;
try {
const result = await defaultFS.promises[method]!(filePath, ...args);
// Uncomment this to debug operations
// console.log('[routablefs] Executing', method, filePath, { args }, { result });
return result;
} catch (e) {
if (e instanceof Error && e.message.startsWith('ENNOENT') && method === 'writeFile') {
console.log(`[routablefs] Creating directory for ${filePath}`);
await defaultFS.promises.mkdir!(path.dirname(filePath), { recursive: true });
return defaultFS.promises[method]!(filePath, ...args);
}
}
};

// @ts-expect-error -- TSCONVERSION declare and initialize together to avoid an error
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import React, { useEffect, useRef, useState } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { Heading, ListBox, ListBoxItem, Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { useFetcher, useParams } from 'react-router-dom';

import { docsGitSync } from '../../../../common/documentation';
import type { GitRepository, OauthProviderName } from '../../../../models/git-repository';
import type { CloneGitActionResult } from '../../../routes/git-actions';
import { scopeToBgColorMap, scopeToIconMap, scopeToLabelMap, scopeToTextColorMap } from '../../../routes/project';
import { Link } from '../../base/link';
import { Modal, type ModalHandle, type ModalProps } from '../../base/modal';
import { ModalBody } from '../../base/modal-body';
import { ModalFooter } from '../../base/modal-footer';
import { ModalHeader } from '../../base/modal-header';
import { ErrorBoundary } from '../../error-boundary';
import { HelpTooltip } from '../../help-tooltip';
import { Icon } from '../../icon';
import { showAlert } from '..';
import { CustomRepositorySettingsFormGroup } from './custom-repository-settings-form-group';
import { GitHubRepositorySetupFormGroup } from './github-repository-settings-form-group';
Expand All @@ -19,7 +22,8 @@ import { GitLabRepositorySetupFormGroup } from './gitlab-repository-settings-for
export const GitRepositoryCloneModal = (props: ModalProps) => {
const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string };
const modalRef = useRef<ModalHandle>(null);
const cloneGitRepositoryFetcher = useFetcher();
const cloneGitRepositoryFetcher = useFetcher<CloneGitActionResult>();
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);

const [selectedTab, setTab] = useState<OauthProviderName>('github');

Expand All @@ -45,6 +49,7 @@ export const GitRepositoryCloneModal = (props: ModalProps) => {
authorName: author?.name || '',
authorEmail: author?.email || '',
...credentials,
workspaceId: selectedWorkspaceId || '',
},
{
action: `/organization/${organizationId}/project/${projectId}/git/clone`,
Expand All @@ -54,10 +59,12 @@ export const GitRepositoryCloneModal = (props: ModalProps) => {
};

const isSubmitting = cloneGitRepositoryFetcher.state === 'submitting';
const errors = cloneGitRepositoryFetcher.data?.errors as (Error | string)[];
const errors = cloneGitRepositoryFetcher.data && 'errors' in cloneGitRepositoryFetcher.data && cloneGitRepositoryFetcher.data.errors;
const workspaces = cloneGitRepositoryFetcher.data && 'workspaces' in cloneGitRepositoryFetcher.data && cloneGitRepositoryFetcher.data.workspaces;

useEffect(() => {
if (errors && errors.length) {
const errorMessage = errors.map(e => e instanceof Error ? e.message : typeof e === 'string' && e).join(', ');
const errorMessage = errors.join(', ');

showAlert({
title: 'Error Cloning Repository',
Expand All @@ -77,14 +84,41 @@ export const GitRepositoryCloneModal = (props: ModalProps) => {
</HelpTooltip>
</ModalHeader>
<ModalBody>
{workspaces && workspaces.length > 0 && (
<>
<Heading className='text-xl mb-2'>Choose which file you want to clone:</Heading>
<ListBox
onAction={key => {
setSelectedWorkspaceId(key.toString());
}}
items={workspaces.map(workspace => ({
id: workspace._id,
...workspace,
isSelected: workspace._id === selectedWorkspaceId,
}))}
>
{item => (
<ListBoxItem className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors">
<div className={`${scopeToBgColorMap[item.scope]} ${scopeToTextColorMap[item.scope]} px-2 flex justify-center items-center h-[20px] w-[20px] rounded-s-sm`}>
<Icon icon={scopeToIconMap[item.scope]} />
</div>
<span>{item.name}</span>
<span className='text-[--hl]'>{scopeToLabelMap[item.scope]}</span>
{item.isSelected && <i className="fa fa-check text-[--color-success]" />}
</ListBoxItem>
)}
</ListBox>
</>
)
}
<ErrorBoundary>
<Tabs
selectedKey={selectedTab}
onSelectionChange={key => {
setTab(key as OauthProviderName);
}}
aria-label='Git repository settings tabs'
className="flex-1 w-full h-full flex flex-col"
className={`flex-1 w-full h-full flex flex-col ${workspaces && workspaces.length > 0 ? 'hidden' : ''}`}
>
<TabList className='w-full flex-shrink-0 overflow-x-auto border-solid scro border-b border-b-[--hl-md] bg-[--color-bg] flex items-center h-[--line-height-sm]' aria-label='Request pane tabs'>
<Tab
Expand Down
2 changes: 1 addition & 1 deletion packages/insomnia/src/ui/routes/design.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const loader: LoaderFunction = async ({
}): Promise<LoaderData> => {
const { workspaceId } = params;
invariant(workspaceId, 'Workspace ID is required');
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
const apiSpec = await models.apiSpec.getOrCreateForParentId(workspaceId);
invariant(apiSpec, 'API spec not found');
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
Expand Down
60 changes: 41 additions & 19 deletions packages/insomnia/src/ui/routes/git-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,11 @@ export const canPushLoader: LoaderFunction = async ({ params }): Promise<GitCanP
};

// Actions
type CloneGitActionResult =
export type CloneGitActionResult =
| Response
| {
workspaces: Workspace[];
}
| {
errors?: string[];
};
Expand Down Expand Up @@ -495,7 +498,7 @@ export const cloneGitRepoAction: ActionFunction = async ({

// Stop the DB from pushing updates to the UI temporarily
const bufferId = await database.bufferChanges();
let workspaceId = '';
let workspaceId = formData.get('workspaceId')?.toString() || '';
let scope: 'design' | 'collection' = WorkspaceScopeKeys.design;
// If no workspace exists we create a new one
if (!(await containsInsomniaWorkspaceDir(fsClient))) {
Expand All @@ -522,7 +525,7 @@ export const cloneGitRepoAction: ActionFunction = async ({
} else {
// Clone all entities from the repository
const workspaceBase = path.join(GIT_INSOMNIA_DIR, models.workspace.type);
const workspaces = await fsClient.promises.readdir(workspaceBase);
const workspaces: string[] = await fsClient.promises.readdir(workspaceBase);

if (workspaces.length === 0) {
window.main.trackSegmentEvent({
Expand All @@ -537,27 +540,27 @@ export const cloneGitRepoAction: ActionFunction = async ({
};
}

if (workspaces.length > 1) {
window.main.trackSegmentEvent({
event: SegmentEvent.vcsSyncComplete, properties: {
...vcsSegmentEventProperties(
'git',
'clone',
'multiple workspaces found'
),
providerName,
},
});
if (workspaces.length > 1 && !workspaceId) {
const allWorkspaces = await Promise.all(workspaces.map(async workspaceFileName => {
const workspacePath = path.join(workspaceBase, workspaceFileName);
Dismissed Show dismissed Hide dismissed
const workspaceYaml = await fsClient.promises.readFile(workspacePath);

const workspace = YAML.parse(workspaceYaml.toString());

return workspace as Workspace;
}));
return {
errors: ['Multiple workspaces found in repository. Expected one.'],
workspaces: allWorkspaces,
};
}

// Only one workspace
const workspacePath = path.join(workspaceBase, workspaces[0]);
const workspaceJson = await fsClient.promises.readFile(workspacePath);
const workspace = YAML.parse(workspaceJson.toString());
const selectedWorkspace = workspaces.find(workspaceFileName => {
return workspaceFileName.includes(workspaceId);
}) || workspaces[0];
const workspacePath = path.join(workspaceBase, selectedWorkspace);
const workspaceYaml = await fsClient.promises.readFile(workspacePath);
const workspace = YAML.parse(workspaceYaml.toString());
scope = (workspace.scope === WorkspaceScopeKeys.collection) ? WorkspaceScopeKeys.collection : WorkspaceScopeKeys.design;
// Check if the workspace already exists
const existingWorkspace = await models.workspace.getById(workspace._id);
Expand All @@ -574,6 +577,7 @@ export const cloneGitRepoAction: ActionFunction = async ({
return redirect(`/organization/${organizationId}/project/${project._id}/workspace/${existingWorkspace._id}/debug`);
}

const allWorkspaces = [];
// Loop over all model folders in root
for (const modelType of await fsClient.promises.readdir(GIT_INSOMNIA_DIR)) {
const modelDir = path.join(GIT_INSOMNIA_DIR, modelType);
Expand All @@ -583,17 +587,35 @@ export const cloneGitRepoAction: ActionFunction = async ({
const docPath = path.join(modelDir, docFileName);
const docYaml = await fsClient.promises.readFile(docPath);
const doc: models.BaseModel = YAML.parse(docYaml.toString());
const existingRecord = await database.get(doc.type, doc._id);

// Skip existing records
if (existingRecord) {
continue;
}

if (isWorkspace(doc)) {
doc.parentId = project._id;
doc.scope = scope;
const workspace = await database.upsert(doc);
workspaceId = workspace._id;
allWorkspaces.push(workspace);
workspaceId = workspaceId || workspace._id;
} else {
await database.upsert(doc);
}
}
}

// Remove files for other workspaces
for (const workspace of allWorkspaces) {
if (workspace._id !== workspaceId) {
const allRelatedRecords = await database.withDescendants(workspace);
for (const record of allRelatedRecords) {
await database.remove(record);
}
}
}

// Store GitRepository settings and set it as active
await createGitRepository(workspace._id, repoSettingsPatch);
}
Expand Down
Loading