From 6463d7095f3ecb4bfc45bef85cc713b08aa4e91c Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 2 Mar 2023 14:33:34 +0100 Subject: [PATCH] Feat/git improvements (#5779) * fix git repo tab styles * remove extra fetches * add loading indicator for git changes * remote branches and git fetch improvements * fixes * fix tests * fetch before getting the git log * only reinit vcs for different repos * fix make commit button disabled if commiting * show close button in settings modal * fix tests, only fetch from server if there is a remote uri --- .../sync/git/__tests__/git-rollback.test.ts | 8 +- .../src/sync/git/__tests__/git-vcs.test.ts | 186 ++++---- packages/insomnia/src/sync/git/git-vcs.ts | 228 ++++++++-- .../dropdowns/git-sync-dropdown.tsx | 234 +++++++--- .../components/modals/git-branches-modal.tsx | 36 +- .../ui/components/modals/git-log-modal.tsx | 32 +- .../git-repo-clone-modal.tsx | 13 +- .../git-repository-settings-modal.tsx | 42 +- .../components/modals/git-staging-modal.tsx | 11 +- packages/insomnia/src/ui/index.tsx | 12 + .../insomnia/src/ui/routes/git-actions.tsx | 411 ++++++++++-------- 11 files changed, 811 insertions(+), 402 deletions(-) diff --git a/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts b/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts index 48b2a3903..ae5b74047 100644 --- a/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import type { FileWithStatus } from '../git-rollback'; import { gitRollback } from '../git-rollback'; -import { GIT_CLONE_DIR, GIT_INSOMNIA_DIR, GitVCS } from '../git-vcs'; +import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR } from '../git-vcs'; import { MemClient } from '../mem-client'; import { setupDateMocks } from './util'; @@ -13,7 +13,7 @@ describe('git rollback', () => { const unlinkMock = jest.fn().mockResolvedValue(undefined); const undoPendingChangesMock = jest.fn().mockResolvedValue(undefined); - let vcs: Partial = {}; + let vcs: Partial = {}; beforeEach(() => { jest.resetAllMocks(); @@ -126,8 +126,10 @@ describe('git rollback', () => { await fsClient.promises.writeFile(fooTxt, 'foo'); await fsClient.promises.writeFile(barTxt, 'bar'); await fsClient.promises.writeFile(bazTxt, originalContent); - const vcs = new GitVCS(); + const vcs = GitVCS; await vcs.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); diff --git a/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts b/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts index bd9bba65c..97b6c4ed0 100644 --- a/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@je import * as git from 'isomorphic-git'; import path from 'path'; -import { GIT_CLONE_DIR, GIT_INSOMNIA_DIR, GitVCS } from '../git-vcs'; +import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR } from '../git-vcs'; import { MemClient } from '../mem-client'; import { setupDateMocks } from './util'; @@ -22,18 +22,20 @@ describe('Git-VCS', () => { describe('common operations', () => { it('listFiles()', async () => { const fsClient = MemClient.createClient(); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); - await vcs.setAuthor('Karen Brown', 'karen@example.com'); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); // No files exist yet - const files1 = await vcs.listFiles(); + const files1 = await GitVCS.listFiles(); expect(files1).toEqual([]); // File does not exist in git index await fsClient.promises.writeFile('foo.txt', 'bar'); - const files2 = await vcs.listFiles(); + const files2 = await GitVCS.listFiles(); expect(files2).toEqual([]); }); @@ -44,31 +46,35 @@ describe('Git-VCS', () => { await fsClient.promises.writeFile(barTxt, 'bar'); // Files outside namespace should be ignored await fsClient.promises.writeFile('/other.txt', 'other'); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('*added'); - await vcs.add(fooTxt); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('added'); - await vcs.remove(fooTxt); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('*added'); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('*added'); + await GitVCS.add(fooTxt); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('added'); + await GitVCS.remove(fooTxt); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('*added'); }); it('Returns empty log without first commit', async () => { const fsClient = MemClient.createClient(); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - expect(await vcs.log()).toEqual([]); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + expect(await GitVCS.log()).toEqual([]); }); it('commit file', async () => { @@ -77,17 +83,19 @@ describe('Git-VCS', () => { await fsClient.promises.writeFile(fooTxt, 'foo'); await fsClient.promises.writeFile(barTxt, 'bar'); await fsClient.promises.writeFile('other.txt', 'should be ignored'); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - await vcs.add(fooTxt); - await vcs.commit('First commit!'); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('unmodified'); - expect(await vcs.log()).toEqual([ + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + await GitVCS.add(fooTxt); + await GitVCS.commit('First commit!'); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('unmodified'); + expect(await GitVCS.log()).toEqual([ { commit: { author: { @@ -116,14 +124,14 @@ First commit! }, ]); await fsClient.promises.unlink(fooTxt); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('*deleted'); - await vcs.remove(fooTxt); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('deleted'); - await vcs.remove(fooTxt); - expect(await vcs.status(barTxt)).toBe('*added'); - expect(await vcs.status(fooTxt)).toBe('deleted'); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('*deleted'); + await GitVCS.remove(fooTxt); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('deleted'); + await GitVCS.remove(fooTxt); + expect(await GitVCS.status(barTxt)).toBe('*added'); + expect(await GitVCS.status(fooTxt)).toBe('deleted'); }); it('create branch', async () => { @@ -131,22 +139,24 @@ First commit! await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); await fsClient.promises.writeFile(fooTxt, 'foo'); await fsClient.promises.writeFile(barTxt, 'bar'); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - await vcs.add(fooTxt); - await vcs.commit('First commit!'); - expect((await vcs.log()).length).toBe(1); - await vcs.checkout('new-branch'); - expect((await vcs.log()).length).toBe(1); - await vcs.add(barTxt); - await vcs.commit('Second commit!'); - expect((await vcs.log()).length).toBe(2); - await vcs.checkout('master'); - expect((await vcs.log()).length).toBe(1); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + await GitVCS.add(fooTxt); + await GitVCS.commit('First commit!'); + expect((await GitVCS.log()).length).toBe(1); + await GitVCS.checkout('new-branch'); + expect((await GitVCS.log()).length).toBe(1); + await GitVCS.add(barTxt); + await GitVCS.commit('Second commit!'); + expect((await GitVCS.log()).length).toBe(2); + await GitVCS.checkout('main'); + expect((await GitVCS.log()).length).toBe(1); }); }); @@ -156,8 +166,8 @@ First commit! ok: ['unpack'], errors: ['refs/heads/master pre-receive hook declined'], }); - const vcs = new GitVCS(); - await expect(vcs.push()).rejects.toThrowError( + + await expect(GitVCS.push()).rejects.toThrowError( 'Push rejected with errors: ["refs/heads/master pre-receive hook declined"].\n\nGo to View > Toggle DevTools > Console for more information.', ); }); @@ -173,26 +183,28 @@ First commit! await fsClient.promises.writeFile(fooTxt, originalContent); await fsClient.promises.mkdir(folder); await fsClient.promises.writeFile(folderBarTxt, originalContent); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); // Commit - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - await vcs.add(fooTxt); - await vcs.add(folderBarTxt); - await vcs.commit('First commit!'); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + await GitVCS.add(fooTxt); + await GitVCS.add(folderBarTxt); + await GitVCS.commit('First commit!'); // Change the file await fsClient.promises.writeFile(fooTxt, 'changedContent'); await fsClient.promises.writeFile(folderBarTxt, 'changedContent'); - expect(await vcs.status(fooTxt)).toBe('*modified'); - expect(await vcs.status(folderBarTxt)).toBe('*modified'); + expect(await GitVCS.status(fooTxt)).toBe('*modified'); + expect(await GitVCS.status(folderBarTxt)).toBe('*modified'); // Undo - await vcs.undoPendingChanges(); + await GitVCS.undoPendingChanges(); // Ensure git doesn't recognize a change anymore - expect(await vcs.status(fooTxt)).toBe('unmodified'); - expect(await vcs.status(folderBarTxt)).toBe('unmodified'); + expect(await GitVCS.status(fooTxt)).toBe('unmodified'); + expect(await GitVCS.status(folderBarTxt)).toBe('unmodified'); // Expect original doc to have reverted expect((await fsClient.promises.readFile(fooTxt)).toString()).toBe(originalContent); expect((await fsClient.promises.readFile(folderBarTxt)).toString()).toBe(originalContent); @@ -207,29 +219,31 @@ First commit! const changedContent = 'changedContent'; const fsClient = MemClient.createClient(); await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); // Write to all files await Promise.all(files.map(f => fsClient.promises.writeFile(f, originalContent))); // Commit all files - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - await Promise.all(files.map(f => vcs.add(f, originalContent))); - await vcs.commit('First commit!'); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + await Promise.all(files.map(f => GitVCS.add(f, originalContent))); + await GitVCS.commit('First commit!'); // Change all files await Promise.all(files.map(f => fsClient.promises.writeFile(f, changedContent))); - await Promise.all(files.map(() => expect(vcs.status(foo1Txt)).resolves.toBe('*modified'))); + await Promise.all(files.map(() => expect(GitVCS.status(foo1Txt)).resolves.toBe('*modified'))); // Undo foo1 and foo2, but not foo3 - await vcs.undoPendingChanges([foo1Txt, foo2Txt]); - expect(await vcs.status(foo1Txt)).toBe('unmodified'); - expect(await vcs.status(foo2Txt)).toBe('unmodified'); + await GitVCS.undoPendingChanges([foo1Txt, foo2Txt]); + expect(await GitVCS.status(foo1Txt)).toBe('unmodified'); + expect(await GitVCS.status(foo2Txt)).toBe('unmodified'); // Expect original doc to have reverted for foo1 and foo2 expect((await fsClient.promises.readFile(foo1Txt)).toString()).toBe(originalContent); expect((await fsClient.promises.readFile(foo2Txt)).toString()).toBe(originalContent); // Expect changed content for foo3 - expect(await vcs.status(foo3Txt)).toBe('*modified'); + expect(await GitVCS.status(foo3Txt)).toBe('*modified'); expect((await fsClient.promises.readFile(foo3Txt)).toString()).toBe(changedContent); }); }); @@ -242,23 +256,25 @@ First commit! await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); await fsClient.promises.mkdir(dir); await fsClient.promises.writeFile(dirFooTxt, 'foo'); - const vcs = new GitVCS(); - await vcs.init({ + + await GitVCS.init({ + uri: '', + repoId: '', directory: GIT_CLONE_DIR, fs: fsClient, }); - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - await vcs.add(dirFooTxt); - await vcs.commit('First'); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); + await GitVCS.add(dirFooTxt); + await GitVCS.commit('First'); await fsClient.promises.writeFile(dirFooTxt, 'foo bar'); - await vcs.add(dirFooTxt); - await vcs.commit('Second'); - const log = await vcs.log(); - expect(await vcs.readObjFromTree(log[0].commit.tree, dirFooTxt)).toBe('foo bar'); - expect(await vcs.readObjFromTree(log[1].commit.tree, dirFooTxt)).toBe('foo'); + await GitVCS.add(dirFooTxt); + await GitVCS.commit('Second'); + const log = await GitVCS.log(); + expect(await GitVCS.readObjFromTree(log[0].commit.tree, dirFooTxt)).toBe('foo bar'); + expect(await GitVCS.readObjFromTree(log[1].commit.tree, dirFooTxt)).toBe('foo'); // Some extra checks - expect(await vcs.readObjFromTree(log[1].commit.tree, 'missing')).toBe(null); - expect(await vcs.readObjFromTree('missing', 'missing')).toBe(null); + expect(await GitVCS.readObjFromTree(log[1].commit.tree, 'missing')).toBe(null); + expect(await GitVCS.readObjFromTree('missing', 'missing')).toBe(null); }); }); }); diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index f2229a3e6..1013b30c8 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -25,7 +25,7 @@ interface GitCredentialsOAuth { * Supported OAuth formats. * This is needed by isomorphic-git to be able to push/pull using an oauth2 token. * https://isomorphic-git.org/docs/en/authentication.html - */ + */ oauth2format?: 'github' | 'gitlab'; username: string; token: string; @@ -33,7 +33,9 @@ interface GitCredentialsOAuth { export type GitCredentials = GitCredentialsBase | GitCredentialsOAuth; -export const isGitCredentialsOAuth = (credentials: GitCredentials): credentials is GitCredentialsOAuth => { +export const isGitCredentialsOAuth = ( + credentials: GitCredentials +): credentials is GitCredentialsOAuth => { return 'oauth2format' in credentials; }; @@ -62,6 +64,9 @@ interface InitOptions { directory: string; fs: git.FsClient; gitDirectory?: string; + gitCredentials?: GitCredentials | null; + uri?: string; + repoId: string; } interface InitFromCloneOptions { @@ -70,6 +75,7 @@ interface InitFromCloneOptions { directory: string; fs: git.FsClient; gitDirectory: string; + repoId: string; } /** @@ -94,38 +100,77 @@ interface BaseOpts { onAuthFailure: git.AuthFailureCallback; onAuthSuccess: git.AuthSuccessCallback; onAuth: git.AuthCallback; + uri: string; + repoId: string; } export class GitVCS { // @ts-expect-error -- TSCONVERSION not initialized with required properties _baseOpts: BaseOpts = gitCallbacks(); - initialized: boolean; + initializedRepoId = ''; - constructor() { - this.initialized = false; - } - - async init({ directory, fs, gitDirectory }: InitOptions) { + async init({ directory, fs, gitDirectory, gitCredentials, uri = '', repoId }: InitOptions) { this._baseOpts = { ...this._baseOpts, dir: directory, + ...gitCallbacks(gitCredentials), gitdir: gitDirectory, fs, http: httpClient, + uri, + repoId, }; if (await this.repoExists()) { console.log(`[git] Opened repo for ${gitDirectory}`); } else { console.log(`[git] Initialized repo in ${gitDirectory}`); - await git.init(this._baseOpts); - } + let defaultBranch = 'main'; - this.initialized = true; + try { + const url = await this.getRemoteOriginURI(); + if (!url) { + throw new Error('No remote origin URL'); + } + const [mainRef] = await git.listServerRefs({ + ...this._baseOpts, + url, + prefix: 'HEAD', + symrefs: true, + }); + + defaultBranch = mainRef?.target?.replace('refs/heads/', '') || 'main'; + } catch (err) { + // Ignore error + } + + await git.init({ ...this._baseOpts, defaultBranch }); + } } - async initFromClone({ url, gitCredentials, directory, fs, gitDirectory }: InitFromCloneOptions) { + async getRemoteOriginURI() { + try { + const remoteOriginURI = await git.getConfig({ + ...this._baseOpts, + path: 'remote.origin.url', + }); + + return remoteOriginURI; + } catch (err) { + // Ignore error + return this._baseOpts.uri || ''; + } + } + + async initFromClone({ + repoId, + url, + gitCredentials, + directory, + fs, + gitDirectory, + }: InitFromCloneOptions) { this._baseOpts = { ...this._baseOpts, ...gitCallbacks(gitCredentials), @@ -133,21 +178,26 @@ export class GitVCS { gitdir: gitDirectory, fs, http: httpClient, + repoId, }; - const cloneParams = { ...this._baseOpts, url, singleBranch: true }; - await git.clone(cloneParams); + await git.clone({ + ...this._baseOpts, + url, + singleBranch: true, + }); console.log(`[git] Clones repo to ${gitDirectory} from ${url}`); - this.initialized = true; } - isInitialized() { - return this.initialized; + isInitializedForRepo(id: string) { + return this._baseOpts.repoId === id; } async listFiles() { console.log('[git] List files'); const repositoryFiles = await git.listFiles({ ...this._baseOpts }); - const insomniaFiles = repositoryFiles.filter(file => file.startsWith(GIT_INSOMNIA_DIR_NAME)).map(convertToOsSep); + const insomniaFiles = repositoryFiles + .filter(file => file.startsWith(GIT_INSOMNIA_DIR_NAME)) + .map(convertToOsSep); return insomniaFiles; } @@ -170,17 +220,48 @@ export class GitVCS { branches.push(branch); } + console.log( + `[git] Local branches: ${branches.join(', ')} (current: ${branch})` + ); + return GitVCS.sortBranches(branches); } async listRemoteBranches() { - const branches = await git.listBranches({ ...this._baseOpts, remote: 'origin' }); + const branches = await git.listBranches({ + ...this._baseOpts, + remote: 'origin', + }); // Don't care about returning remote HEAD return GitVCS.sortBranches(branches.filter(b => b !== 'HEAD')); } + async fetchRemoteBranches() { + const uri = await this.getRemoteOriginURI(); + try { + const branches = await git.listServerRefs({ + ...this._baseOpts, + prefix: 'refs/heads/', + url: uri, + }); + console.log({ branches }); + // Don't care about returning remote HEAD + return GitVCS.sortBranches( + branches + .filter(b => b.ref !== 'HEAD') + .map(b => b.ref.replace('refs/heads/', '')) + ); + } catch (e) { + console.log(`[git] Failed to list remote branches for ${uri}`, e); + return []; + } + } + async status(filepath: string) { - return git.status({ ...this._baseOpts, filepath: convertToPosixSep(filepath) }); + return git.status({ + ...this._baseOpts, + filepath: convertToPosixSep(filepath), + }); } async add(relPath: string) { @@ -197,7 +278,12 @@ export class GitVCS { async addRemote(url: string) { console.log(`[git] Add Remote url=${url}`); - await git.addRemote({ ...this._baseOpts, remote: 'origin', url, force: true }); + await git.addRemote({ + ...this._baseOpts, + remote: 'origin', + url, + force: true, + }); const config = await this.getRemote('origin'); if (config === null) { @@ -213,7 +299,10 @@ export class GitVCS { async getAuthor() { const name = await git.getConfig({ ...this._baseOpts, path: 'user.name' }); - const email = await git.getConfig({ ...this._baseOpts, path: 'user.email' }); + const email = await git.getConfig({ + ...this._baseOpts, + path: 'user.email', + }); return { name: name || '', email: email || '', @@ -222,7 +311,11 @@ export class GitVCS { async setAuthor(name: string, email: string) { await git.setConfig({ ...this._baseOpts, path: 'user.name', value: name }); - await git.setConfig({ ...this._baseOpts, path: 'user.email', value: email }); + await git.setConfig({ + ...this._baseOpts, + path: 'user.email', + value: email, + }); } async getRemote(name: string): Promise { @@ -257,7 +350,7 @@ export class GitVCS { forPush: true, url: remote.url, }); - const logs = (await this.log(1)) || []; + const logs = (await this.log({ depth: 1 })) || []; const localHead = logs[0].oid; const remoteRefs = remoteInfo.refs || {}; const remoteHeads = remoteRefs.heads || {}; @@ -286,7 +379,7 @@ export class GitVCS { // @ts-expect-error -- TSCONVERSION git errors are not handled correctly const errorsString = JSON.stringify(response.errors); throw new Error( - `Push rejected with errors: ${errorsString}.\n\nGo to View > Toggle DevTools > Console for more information.`, + `Push rejected with errors: ${errorsString}.\n\nGo to View > Toggle DevTools > Console for more information.` ); } } @@ -311,28 +404,48 @@ export class GitVCS { }); } - async fetch( - singleBranch: boolean, - depth?: number, - gitCredentials?: GitCredentials | null, - ) { + async fetch({ + singleBranch, + depth, + credentials, + relative = false, + }: { + singleBranch: boolean; + depth?: number; + credentials?: GitCredentials | null; + relative?: boolean; + }) { console.log('[git] Fetch remote=origin'); return git.fetch({ ...this._baseOpts, - ...gitCallbacks(gitCredentials), + ...gitCallbacks(credentials), singleBranch, remote: 'origin', + relative, depth, prune: true, pruneTags: true, + }); } - async log(depth?: number) { + async log(input: {depth?: number} = {}) { + const { depth = 35 } = input; try { + const remoteOriginURI = await this.getRemoteOriginURI(); + if (remoteOriginURI) { + await git.fetch({ + ...this._baseOpts, + remote: 'origin', + depth, + singleBranch: true, + tags: false, + }); + } + return await git.log({ ...this._baseOpts, depth }); - } catch (error) { - if (error.code === 'NotFoundError') { + } catch (error: unknown) { + if (error instanceof git.Errors.NotFoundError) { return []; } @@ -341,8 +454,18 @@ export class GitVCS { } async branch(branch: string, checkout = false) { - // @ts-expect-error -- TSCONVERSION remote doesn't exist as an option - await git.branch({ ...this._baseOpts, ref: branch, checkout, remote: 'origin' }); + console.log('[git] Branch', { + branch, + checkout, + }); + + await git.branch({ + ...this._baseOpts, + ref: branch, + checkout, + // @ts-expect-error -- TSCONVERSION remote doesn't exist as an option + remote: 'origin', + }); } async deleteBranch(branch: string) { @@ -354,19 +477,39 @@ export class GitVCS { branch, }); const localBranches = await this.listBranches(); - const remoteBranches = await this.listRemoteBranches(); - const branches = [...localBranches, ...remoteBranches]; + const syncedBranches = await this.listRemoteBranches(); + const remoteBranches = await this.fetchRemoteBranches(); + const branches = [...localBranches, ...syncedBranches, ...remoteBranches]; + console.log('[git] Checkout branches', { branches, branch }); if (branches.includes(branch)) { + try { + if (!syncedBranches.includes(branch)) { + console.log('[git] Fetching branch', branch); + // Try to fetch the branch from the remote if it doesn't exist locally; + await git.fetch({ + ...this._baseOpts, + remote: 'origin', + depth: 1, + ref: branch, + singleBranch: true, + tags: false, + }); + } + } catch (e) { + console.log('[git] Fetch failed', e); + } + await git.checkout({ ...this._baseOpts, ref: branch, remote: 'origin', }); + const branches = await this.listBranches(); + console.log('[git] Checkout branches', { branches }); } else { await this.branch(branch, true); } - } async undoPendingChanges(fileFilter?: string[]) { @@ -423,9 +566,4 @@ export class GitVCS { } } -let gitVCSInstance = new GitVCS(); - -export const getGitVCS = () => gitVCSInstance; -export const setGitVCS = (gitVCS: GitVCS) => { - gitVCSInstance = gitVCS; -}; +export default new GitVCS(); diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index 1cee06486..6ae89a47a 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -5,8 +5,20 @@ import { useFetcher, useParams } from 'react-router-dom'; import { docsGitSync } from '../../../common/documentation'; import { GitRepository } from '../../../models/git-repository'; import { getOauth2FormatName } from '../../../sync/git/utils'; -import { GitRepoLoaderData, PullFromGitRemoteResult, PushToGitRemoteResult } from '../../routes/git-actions'; -import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown'; +import { + GitFetchLoaderData, + GitRepoLoaderData, + PullFromGitRemoteResult, + PushToGitRemoteResult, +} from '../../routes/git-actions'; +import { + type DropdownHandle, + Dropdown, + DropdownButton, + DropdownItem, + DropdownSection, + ItemContent, +} from '../base/dropdown'; import { Link } from '../base/link'; import { HelpTooltip } from '../help-tooltip'; import { showAlert } from '../modals'; @@ -22,10 +34,15 @@ interface Props { } export const GitSyncDropdown: FC = ({ className, gitRepository }) => { - const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; workspaceId: string }; + const { organizationId, projectId, workspaceId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; const dropdownRef = useRef(null); - const [isGitRepoSettingsModalOpen, setIsGitRepoSettingsModalOpen] = useState(false); + const [isGitRepoSettingsModalOpen, setIsGitRepoSettingsModalOpen] = + useState(false); const [isGitBranchesModalOpen, setIsGitBranchesModalOpen] = useState(false); const [isGitLogModalOpen, setIsGitLogModalOpen] = useState(false); const [isGitStagingModalOpen, setIsGitStagingModalOpen] = useState(false); @@ -34,20 +51,34 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { const gitPullFetcher = useFetcher(); const gitCheckoutFetcher = useFetcher(); const gitRepoDataFetcher = useFetcher(); + const gitFetchFetcher = useFetcher(); const loadingPush = gitPushFetcher.state === 'loading'; const loadingPull = gitPullFetcher.state === 'loading'; + const loadingFetch = gitFetchFetcher.state === 'loading'; useEffect(() => { - if (gitRepository?.uri && gitRepository?._id && gitRepoDataFetcher.state === 'idle' && !gitRepoDataFetcher.data) { - gitRepoDataFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/repo`); + if ( + gitRepository?.uri && + gitRepository?._id && + gitRepoDataFetcher.state === 'idle' && + !gitRepoDataFetcher.data + ) { + gitRepoDataFetcher.load( + `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/repo` + ); } - }, [gitRepoDataFetcher, gitRepository?.uri, gitRepository?._id, organizationId, projectId, workspaceId]); + }, [ + gitRepoDataFetcher, + gitRepository?.uri, + gitRepository?._id, + organizationId, + projectId, + workspaceId, + ]); useEffect(() => { - const errors = [ - ...gitPushFetcher.data?.errors ?? [], - ]; + const errors = [...(gitPushFetcher.data?.errors ?? [])]; if (errors.length > 0) { showAlert({ title: 'Push Failed', @@ -57,9 +88,21 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { }, [gitPushFetcher.data?.errors]); useEffect(() => { - const errors = [ - ...gitPullFetcher.data?.errors ?? [], - ]; + const gitRepoDataErrors = + gitRepoDataFetcher.data && 'errors' in gitRepoDataFetcher.data + ? gitRepoDataFetcher.data.errors + : []; + const errors = [...gitRepoDataErrors]; + if (errors.length > 0) { + showAlert({ + title: 'Loading of Git Repository Failed', + message: errors.join('\n'), + }); + } + }, [gitRepoDataFetcher.data]); + + useEffect(() => { + const errors = [...(gitPullFetcher.data?.errors ?? [])]; if (errors.length > 0) { showAlert({ title: 'Pull Failed', @@ -69,9 +112,7 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { }, [gitPullFetcher.data?.errors]); useEffect(() => { - const errors = [ - ...gitCheckoutFetcher.data?.errors ?? [], - ]; + const errors = [...(gitCheckoutFetcher.data?.errors ?? [])]; if (errors.length > 0) { showAlert({ title: 'Checkout Failed', @@ -81,12 +122,15 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { }, [gitCheckoutFetcher.data?.errors]); async function handlePush({ force }: { force: boolean }) { - gitPushFetcher.submit({ - force: `${force}`, - }, { - action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/push`, - method: 'post', - }); + gitPushFetcher.submit( + { + force: `${force}`, + }, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/push`, + method: 'post', + } + ); } let iconClassName = ''; @@ -98,10 +142,21 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { iconClassName = 'fa fa-gitlab'; } - const isLoading = gitRepoDataFetcher.state === 'loading'; - const isButton = !gitRepository || (isLoading && !gitRepoDataFetcher.data) || (gitRepoDataFetcher.data && 'errors' in gitRepoDataFetcher.data); + const isLoading = + gitRepoDataFetcher.state === 'loading' || + gitFetchFetcher.state === 'loading' || + gitCheckoutFetcher.state === 'loading' || + gitPushFetcher.state === 'loading' || + gitPullFetcher.state === 'loading'; + const isButton = + !gitRepository || + (isLoading && !gitRepoDataFetcher.data) || + (gitRepoDataFetcher.data && 'errors' in gitRepoDataFetcher.data); - const { log, branches, branch: currentBranch, remoteBranches } = (gitRepoDataFetcher.data && 'log' in gitRepoDataFetcher.data) ? gitRepoDataFetcher.data : { log: [], branches: [], branch: '', remoteBranches: [] }; + const { branches, branch: currentBranch } = + gitRepoDataFetcher.data && 'branches' in gitRepoDataFetcher.data + ? gitRepoDataFetcher.data + : { branches: [], branch: '' }; let dropdown: React.ReactNode = null; @@ -118,30 +173,44 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { icon: loadingPull ? 'refresh fa-spin' : 'cloud-download', label: 'Pull', onClick: async () => { - gitPullFetcher.submit({}, { - action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/pull`, - method: 'post', - }); + gitPullFetcher.submit( + {}, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/pull`, + method: 'post', + } + ); }, }, { id: 3, - isDisabled: log.length === 0, - icon: 'clock-o', - label: History ({log.length}), - onClick: () => setIsGitLogModalOpen(true), - }, - ]; - - if (log.length > 0) { - currentBranchActions.splice(1, 0, { - id: 4, stayOpenAfterClick: true, icon: loadingPush ? 'refresh fa-spin' : 'cloud-upload', label: 'Push', onClick: () => handlePush({ force: false }), - }); - } + }, + { + id: 4, + icon: 'clock-o', + label: History, + onClick: () => setIsGitLogModalOpen(true), + }, + { + id: 5, + stayOpenAfterClick: true, + icon: loadingFetch ? 'refresh fa-spin' : 'refresh', + label: 'Fetch', + onClick: () => { + gitFetchFetcher.submit( + {}, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/fetch`, + method: 'post', + } + ); + }, + }, + ]; if (isButton) { dropdown = ( @@ -151,7 +220,11 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { className="btn--clicky-small btn-sync" onClick={() => setIsGitRepoSettingsModalOpen(true)} > - + {isLoading ? 'Loading...' : 'Setup Git Sync'} ); @@ -161,13 +234,26 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { { + gitFetchFetcher.submit( + {}, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/fetch`, + method: 'post', + } + ); + }} triggerButton={ {iconClassName && ( )}
{currentBranch}
- +
} > @@ -184,11 +270,10 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { - } > - + = ({ className, gitRepository }) => { /> - + {currentBranch && ( = ({ className, gitRepository }) => { const isCurrentBranch = branch === currentBranch; return ( - + { - gitCheckoutFetcher.submit({ - branch, - }, { - action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branch/checkout`, - method: 'post', - }); + gitCheckoutFetcher.submit( + { + branch, + }, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branch/checkout`, + method: 'post', + } + ); }} /> @@ -245,11 +331,16 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { title={currentBranch} items={currentBranch ? currentBranchActions : []} > - {({ id, ...action }) => - + {({ id, ...action }) => ( + - } + )}
@@ -259,22 +350,29 @@ export const GitSyncDropdown: FC = ({ className, gitRepository }) => { return ( {dropdown} - {isGitRepoSettingsModalOpen && setIsGitRepoSettingsModalOpen(false)} />} - {isGitBranchesModalOpen && + {isGitRepoSettingsModalOpen && ( + setIsGitRepoSettingsModalOpen(false)} + /> + )} + {isGitBranchesModalOpen && ( setIsGitBranchesModalOpen(false)} /> - } - {isGitLogModalOpen && setIsGitLogModalOpen(false)} />} - {isGitStagingModalOpen && - setIsGitStagingModalOpen(false)} + )} + {isGitLogModalOpen && ( + setIsGitLogModalOpen(false)} /> - } + )} + {isGitStagingModalOpen && ( + setIsGitStagingModalOpen(false)} /> + )} ); }; diff --git a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx index 6ea6fdd91..ef1f3e52c 100644 --- a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx @@ -4,7 +4,7 @@ import { OverlayContainer } from 'react-aria'; import { useFetcher, useParams } from 'react-router-dom'; import { GitRepository } from '../../../models/git-repository'; -import { CreateNewGitBranchResult } from '../../routes/git-actions'; +import { CreateNewGitBranchResult, GitBranchesLoaderData } from '../../routes/git-actions'; import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; @@ -13,10 +13,9 @@ import { PromptButton } from '../base/prompt-button'; import { showAlert } from '.'; type Props = ModalProps & { - branches: string[]; - remoteBranches: string[]; activeBranch: string; gitRepository: GitRepository | null; + branches: string[]; }; export interface GitBranchesModalOptions { @@ -24,10 +23,9 @@ export interface GitBranchesModalOptions { } export const GitBranchesModal: FC = (({ - onHide, branches, + onHide, activeBranch, - remoteBranches, }) => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; workspaceId: string}; const modalRef = useRef(null); @@ -36,12 +34,24 @@ export const GitBranchesModal: FC = (({ modalRef.current?.show(); }, []); + const branchesFetcher = useFetcher(); const checkoutBranchFetcher = useFetcher(); const mergeBranchFetcher = useFetcher(); const newBranchFetcher = useFetcher(); const deleteBranchFetcher = useFetcher(); - const remoteOnlyBranches = remoteBranches.filter(b => !branches.includes(b)); + // const errors = branchesFetcher.data && 'errors' in branchesFetcher.data ? branchesFetcher.data.errors : []; + const { remoteBranches, branches: localBranches } = branchesFetcher.data && 'branches' in branchesFetcher.data ? branchesFetcher.data : { branches: [], remoteBranches: [] }; + + const fetchedBranches = localBranches.length > 0 ? localBranches : branches; + const remoteOnlyBranches = remoteBranches.filter(b => !fetchedBranches.includes(b)); + const isFetchingRemoteBranches = branchesFetcher.state !== 'idle'; + + useEffect(() => { + if (branchesFetcher.state === 'idle' && !branchesFetcher.data) { + branchesFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/branches`); + } + }, [branchesFetcher, organizationId, projectId, workspaceId]); useEffect(() => { if (newBranchFetcher.data?.errors?.length) { @@ -55,7 +65,7 @@ export const GitBranchesModal: FC = (({ return ( - Branches + Branches = (({ - {branches.map(branch => ( + {fetchedBranches.map(branch => ( = (({ + {Boolean(isFetchingRemoteBranches && !remoteOnlyBranches.length) && ( +
+
+ + Fetching remote branches... +
+
+ )} {remoteOnlyBranches.length > 0 && (
- + diff --git a/packages/insomnia/src/ui/components/modals/git-log-modal.tsx b/packages/insomnia/src/ui/components/modals/git-log-modal.tsx index cff050950..50893b93e 100644 --- a/packages/insomnia/src/ui/components/modals/git-log-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-log-modal.tsx @@ -1,7 +1,8 @@ import React, { FC, useEffect, useRef } from 'react'; import { OverlayContainer } from 'react-aria'; +import { useFetcher, useParams } from 'react-router-dom'; -import type { GitLogEntry } from '../../../sync/git/git-vcs'; +import { GitLogLoaderData } from '../../routes/git-actions'; import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; @@ -10,23 +11,40 @@ import { TimeFromNow } from '../time-from-now'; import { Tooltip } from '../tooltip'; type Props = ModalProps & { - logs: GitLogEntry[]; branch: string; }; -export const GitLogModal: FC = ({ branch, logs, onHide }) => { +export const GitLogModal: FC = ({ branch, onHide }) => { + const { organizationId, projectId, workspaceId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; const modalRef = useRef(null); + const gitLogFetcher = useFetcher(); + const isLoading = gitLogFetcher.state !== 'idle'; + useEffect(() => { + if (gitLogFetcher.state === 'idle' && !gitLogFetcher.data) { + gitLogFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/log`); + } + }, [organizationId, projectId, workspaceId, gitLogFetcher]); useEffect(() => { modalRef.current?.show(); }, []); + const { log } = gitLogFetcher.data && 'log' in gitLogFetcher.data ? gitLogFetcher.data : { log: [] }; + return ( - Git History ({logs.length}) + Git History -
Remote BranchesRemote Branches {isFetchingRemoteBranches && }  
+ {isLoading &&
+ + Loading git log... +
} + {!isLoading &&
@@ -34,7 +52,7 @@ export const GitLogModal: FC = ({ branch, logs, onHide }) => { - {logs.map(entry => { + {log.map(entry => { const { commit: { author, message }, oid, @@ -57,7 +75,7 @@ export const GitLogModal: FC = ({ branch, logs, onHide }) => { ); })} -
MessageAuthor
+ }
diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repo-clone-modal.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repo-clone-modal.tsx index be06f82f1..253f0edf1 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repo-clone-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repo-clone-modal.tsx @@ -1,5 +1,6 @@ import React, { Key, useEffect, useRef, useState } from 'react'; import { useFetcher, useParams } from 'react-router-dom'; +import styled from 'styled-components'; import { docsGitSync } from '../../../../common/documentation'; import type { GitRepository, OauthProviderName } from '../../../../models/git-repository'; @@ -16,6 +17,12 @@ import { CustomRepositorySettingsFormGroup } from './custom-repository-settings- import { GitHubRepositorySetupFormGroup } from './github-repository-settings-form-group'; import { GitLabRepositorySetupFormGroup } from './gitlab-repository-settings-form-group'; +const TabPill = styled.div({ + display: 'flex', + gap: 'var(--padding-xs)', + alignItems: 'center', +}); + export const GitRepositoryCloneModal = (props: ModalProps) => { const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string }; const modalRef = useRef(null); @@ -82,21 +89,21 @@ export const GitRepositoryCloneModal = (props: ModalProps) => { selectedKey={selectedTab} onSelectionChange={(key: Key) => setTab(key as OauthProviderName)} > - GitHub}> + GitHub}> - GitLab}> + GitLab}> - Git}> + Git}> { @@ -58,9 +65,9 @@ export const GitRepositorySettingsModal = (props: ModalProps & { ); }; - const isSubmitting = updateGitRepositoryFetcher.state === 'submitting'; + const isLoading = updateGitRepositoryFetcher.state !== 'idle'; + const hasGitRepository = Boolean(gitRepository); const errors = updateGitRepositoryFetcher.data?.errors as (Error | string)[]; - const isDisabled = isSubmitting || Boolean(gitRepository); useEffect(() => { if (errors && errors.length) { @@ -85,12 +92,12 @@ export const GitRepositorySettingsModal = (props: ModalProps & { setTab(key as OauthProviderName)} > - GitHub}> + GitHub}> - GitLab}> + GitLab}> - Git}> + Git}> Reset - + {hasGitRepository ? ( + + ) : ( + + )}
diff --git a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx index f3b77999f..97a9861ef 100644 --- a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx @@ -40,6 +40,8 @@ export const GitStagingModal: FC = ({ const gitCommitFetcher = useFetcher(); const rollbackFetcher = useFetcher(); + const isLoadingGitChanges = gitChangesFetcher.state !== 'idle'; + useEffect(() => { modalRef.current?.show(); }, []); @@ -313,7 +315,11 @@ export const GitStagingModal: FC = ({ )} ) : ( - <>No changes to commit. +
+ {isLoadingGitChanges ? <> + + Loading changes... : 'No changes to commit.'} +
)} @@ -329,8 +335,9 @@ export const GitStagingModal: FC = ({ type="submit" form="git-staging-form" className="btn" - disabled={gitCommitFetcher.state === 'submitting' || !hasChanges} + disabled={gitCommitFetcher.state !== 'idle' || !hasChanges} > + Commit
diff --git a/packages/insomnia/src/ui/index.tsx b/packages/insomnia/src/ui/index.tsx index b6bf44521..adebfe4cb 100644 --- a/packages/insomnia/src/ui/index.tsx +++ b/packages/insomnia/src/ui/index.tsx @@ -259,6 +259,18 @@ const router = createMemoryRouter( path: 'commit', action: async (...args) => (await import('./routes/git-actions')).commitToGitRepoAction(...args), }, + { + path: 'branches', + loader: async (...args) => (await import('./routes/git-actions')).gitBranchesLoader(...args), + }, + { + path: 'log', + loader: async (...args) => (await import('./routes/git-actions')).gitLogLoader(...args), + }, + { + path: 'fetch', + action: async (...args) => (await import('./routes/git-actions')).gitFetchAction(...args), + }, { path: 'branch', children: [ diff --git a/packages/insomnia/src/ui/routes/git-actions.tsx b/packages/insomnia/src/ui/routes/git-actions.tsx index e34227193..3529f2dec 100644 --- a/packages/insomnia/src/ui/routes/git-actions.tsx +++ b/packages/insomnia/src/ui/routes/git-actions.tsx @@ -17,15 +17,12 @@ import { } from '../../models/workspace'; import { fsClient } from '../../sync/git/fs-client'; import { gitRollback } from '../../sync/git/git-rollback'; -import { - getGitVCS, +import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR, GIT_INSOMNIA_DIR_NAME, GIT_INTERNAL_DIR, GitLogEntry, - GitVCS, - setGitVCS, } from '../../sync/git/git-vcs'; import { MemClient } from '../../sync/git/mem-client'; import { NeDBClient } from '../../sync/git/ne-db-client'; @@ -42,9 +39,7 @@ import { SegmentEvent, trackSegmentEvent, vcsSegmentEventProperties } from '../a // Loaders export type GitRepoLoaderData = { branch: string; - log: GitLogEntry[]; branches: string[]; - remoteBranches: string[]; gitRepository: GitRepository | null; } | { errors: string[]; @@ -68,69 +63,62 @@ export const gitRepoLoader: LoaderFunction = async ({ params }): Promise => { + const { workspaceId, projectId } = params; + invariant(typeof workspaceId === 'string', 'Workspace Id is required'); + invariant(typeof projectId === 'string', 'Project Id is required'); + + const workspace = await models.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId); + if (!workspaceMeta?.gitRepositoryId) { + return { + errors: ['Workspace is not linked to a git repository'], + }; + } + + const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId); + invariant(gitRepository, 'Git Repository not found'); + + const branches = await GitVCS.listBranches(); + + const remoteBranches = await GitVCS.fetchRemoteBranches(); + + return { + branches, + remoteBranches, + }; +}; + +export type GitFetchLoaderData = { + errors: string[]; +} | {}; + +export const gitFetchAction: ActionFunction = async ({ params }): Promise => { + const { workspaceId, projectId } = params; + invariant(typeof workspaceId === 'string', 'Workspace Id is required'); + invariant(typeof projectId === 'string', 'Project Id is required'); + + const workspace = await models.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId); + if (!workspaceMeta?.gitRepositoryId) { + return { + errors: ['Workspace is not linked to a git repository'], + }; + } + + const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId); + invariant(gitRepository, 'Git Repository not found'); + + await GitVCS.fetch({ singleBranch: true, depth: 1, credentials: gitRepository?.credentials }); + + return {}; +}; + +export type GitLogLoaderData = { + log: GitLogEntry[]; +} | { + errors: string[]; +}; + +export const gitLogLoader: LoaderFunction = async ({ params }): Promise => { + const { workspaceId, projectId } = params; + invariant(typeof workspaceId === 'string', 'Workspace Id is required'); + invariant(typeof projectId === 'string', 'Project Id is required'); + + const workspace = await models.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId); + if (!workspaceMeta?.gitRepositoryId) { + return { + errors: ['Workspace is not linked to a git repository'], + }; + } + + const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId); + invariant(gitRepository, 'Git Repository not found'); + + const log = await GitVCS.log({ depth: 35 }); + + return { + log, + }; +}; + export interface GitChangesLoaderData { changes: GitChange[]; branch: string; @@ -164,11 +242,9 @@ export const gitChangesLoader: LoaderFunction = async ({ params }): Promise 1) { - trackSegmentEvent(SegmentEvent.vcsSyncComplete, { - ...vcsSegmentEventProperties('git', 'clone', 'multiple workspaces found'), - providerName, - }); - - return { - errors: ['Multiple workspaces found in repository. Expected one.'], - }; - } - - // Only one workspace - const workspacePath = path.join(workspaceBase, workspaces[0]); - const workspaceJson = await fsClient.promises.readFile(workspacePath); - const workspace = YAML.parse(workspaceJson.toString()); - // Check if the workspace already exists - const existingWorkspace = await models.workspace.getById(workspace._id); - - if (existingWorkspace) { - trackSegmentEvent(SegmentEvent.vcsSyncComplete, { - ...vcsSegmentEventProperties('git', 'clone', 'workspace already exists'), - providerName, - }); - return { - errors: [`Workspace ${existingWorkspace.name} already exists. Please delete it before cloning.`], - }; - } - - // Stop the DB from pushing updates to the UI temporarily - const bufferId = await database.bufferChanges(); - - // 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); - - // Loop over all documents in model folder and save them - for (const docFileName of await fsClient.promises.readdir(modelDir)) { - const docPath = path.join(modelDir, docFileName); - const docYaml = await fsClient.promises.readFile(docPath); - const doc: models.BaseModel = YAML.parse(docYaml.toString()); - if (isWorkspace(doc)) { - // @ts-expect-error parentId can be string or null for a workspace - doc.parentId = project?._id || null; - doc.scope = WorkspaceScopeKeys.design; - } - await database.upsert(doc); + return { + errors: ['No workspaces found in repository'], + }; } - } - // Store GitRepository settings and set it as active - await createGitRepository(workspace._id, repoSettingsPatch); + if (workspaces.length > 1) { + trackSegmentEvent(SegmentEvent.vcsSyncComplete, { + ...vcsSegmentEventProperties('git', 'clone', 'multiple workspaces found'), + providerName, + }); + + return { + errors: ['Multiple workspaces found in repository. Expected one.'], + }; + } + + // Only one workspace + const workspacePath = path.join(workspaceBase, workspaces[0]); + const workspaceJson = await fsClient.promises.readFile(workspacePath); + const workspace = YAML.parse(workspaceJson.toString()); + // Check if the workspace already exists + const existingWorkspace = await models.workspace.getById(workspace._id); + + if (existingWorkspace) { + trackSegmentEvent(SegmentEvent.vcsSyncComplete, { + ...vcsSegmentEventProperties('git', 'clone', 'workspace already exists'), + providerName, + }); + return { + errors: [`Workspace ${existingWorkspace.name} already exists. Please delete it before cloning.`], + }; + } + + // 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); + + // Loop over all documents in model folder and save them + for (const docFileName of await fsClient.promises.readdir(modelDir)) { + const docPath = path.join(modelDir, docFileName); + const docYaml = await fsClient.promises.readFile(docPath); + const doc: models.BaseModel = YAML.parse(docYaml.toString()); + if (isWorkspace(doc)) { + doc.parentId = project._id; + doc.scope = WorkspaceScopeKeys.design; + const workspace = await database.upsert(doc); + workspaceId = workspace._id; + } else { + await database.upsert(doc); + } + } + } + + // Store GitRepository settings and set it as active + await createGitRepository(workspace._id, repoSettingsPatch); + } // Flush DB changes await database.flushChanges(bufferId); @@ -386,7 +475,9 @@ export const cloneGitRepoAction: ActionFunction = async ({ providerName, }); - return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${ACTIVITY_SPEC}`); + invariant(workspaceId, 'Workspace ID is required'); + + return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}`); }; export const updateGitRepoAction: ActionFunction = async ({ @@ -525,10 +616,8 @@ export const commitToGitRepoAction: ActionFunction = async ({ const allModified = Boolean(formData.get('allModified')); const allUnversioned = Boolean(formData.get('allUnversioned')); - const vcs = getGitVCS(); - try { - const { changes } = await getGitChanges(vcs, workspace); + const { changes } = await getGitChanges(GitVCS, workspace); const changesToCommit = changes.filter(change => { if (allModified && !change.added) { @@ -540,10 +629,10 @@ export const commitToGitRepoAction: ActionFunction = async ({ }); for (const item of changesToCommit) { - item.status.includes('deleted') ? await vcs.remove(item.path) : await vcs.add(item.path); + item.status.includes('deleted') ? await GitVCS.remove(item.path) : await GitVCS.add(item.path); } - await vcs.commit(message); + await GitVCS.commit(message); const providerName = getOauth2FormatName(repo?.credentials); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'commit'), providerName }); @@ -579,11 +668,9 @@ export const createNewGitBranchAction: ActionFunction = async ({ request, params const branch = formData.get('branch'); invariant(typeof branch === 'string', 'Branch name is required'); - const vcs = getGitVCS(); - try { const providerName = getOauth2FormatName(repo?.credentials); - await vcs.checkout(branch); + await GitVCS.checkout(branch); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'create_branch'), providerName }); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Something went wrong while creating a new branch'; @@ -623,18 +710,9 @@ export const checkoutGitBranchAction: ActionFunction = async ({ const branch = formData.get('branch'); invariant(typeof branch === 'string', 'Branch name is required'); - const vcs = getGitVCS(); - - try { - await vcs.fetch(false, 1, repo?.credentials); - } catch (e) { - console.warn('Error fetching from remote'); - } const bufferId = await database.bufferChanges(); try { - await vcs.checkout(branch); - // Fetch the last 20 commits for the branch - await vcs.fetch(true, 20, repo?.credentials); + await GitVCS.checkout(branch); } catch (err) { const errorMessage = err instanceof Error ? err.message : err; return { @@ -643,7 +721,7 @@ export const checkoutGitBranchAction: ActionFunction = async ({ } if (workspaceMeta) { - const log = (await vcs.log()) || []; + const log = (await GitVCS.log({ depth: 1 })) || []; const author = log[0] ? log[0].commit.author : null; const cachedGitLastCommitTime = author ? author.timestamp * 1000 : null; @@ -681,19 +759,17 @@ export const mergeGitBranchAction: ActionFunction = async ({ request, params }): const repo = await models.gitRepository.getById(repoId); invariant(repo, 'Git Repository not found'); - const vcs = getGitVCS(); - const formData = await request.formData(); const branch = formData.get('branch'); invariant(typeof branch === 'string', 'Branch name is required'); try { const providerName = getOauth2FormatName(repo?.credentials); - await vcs.merge(branch); + await GitVCS.merge(branch); // Apparently merge doesn't update the working dir so need to checkout too const bufferId = await database.bufferChanges(); - await vcs.checkout(branch); + await GitVCS.checkout(branch); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'checkout_branch'), providerName }); await database.flushChanges(bufferId, true); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName }); @@ -726,8 +802,6 @@ export const deleteGitBranchAction: ActionFunction = async ({ request, params }) const repo = await models.gitRepository.getById(repoId); invariant(repo, 'Git Repository not found'); - const vcs = getGitVCS(); - const formData = await request.formData(); const branch = formData.get('branch'); @@ -735,7 +809,7 @@ export const deleteGitBranchAction: ActionFunction = async ({ request, params }) try { const providerName = getOauth2FormatName(repo?.credentials); - await vcs.deleteBranch(branch); + await GitVCS.deleteBranch(branch); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'delete_branch'), providerName }); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; @@ -772,12 +846,10 @@ export const pushToGitRemoteAction: ActionFunction = async ({ invariant(gitRepository, 'Git Repository not found'); - const vcs = getGitVCS(); - // Check if there is anything to push let canPush = false; try { - canPush = await vcs.canPush(gitRepository.credentials); + canPush = await GitVCS.canPush(gitRepository.credentials); } catch (err) { return { errors: ['Error Pushing Repository'] }; } @@ -791,7 +863,7 @@ export const pushToGitRemoteAction: ActionFunction = async ({ const bufferId = await database.bufferChanges(); const providerName = getOauth2FormatName(gitRepository.credentials); try { - await vcs.push(gitRepository.credentials); + await GitVCS.push(gitRepository.credentials); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', force ? 'force_push' : 'push'), providerName }); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown Error'; @@ -836,20 +908,18 @@ export const pullFromGitRemoteAction: ActionFunction = async ({ invariant(gitRepository, 'Git Repository not found'); - const vcs = getGitVCS(); - const bufferId = await database.bufferChanges(); const providerName = getOauth2FormatName(gitRepository.credentials); try { - await vcs.fetch(false, 1, gitRepository?.credentials); + await GitVCS.fetch({ singleBranch: true, depth: 1, credentials: gitRepository?.credentials }); } catch (e) { console.warn('Error fetching from remote'); } try { - await vcs.pull(gitRepository.credentials); + await GitVCS.pull(gitRepository.credentials); trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'pull'), providerName }); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Unknown Error'; @@ -874,7 +944,7 @@ export interface GitChange { editable: boolean; } -async function getGitVCSPaths(vcs: GitVCS) { +async function getGitVCSPaths(vcs: typeof GitVCS) { const gitFS = vcs.getFs(); const fs = 'promises' in gitFS ? gitFS.promises : gitFS; @@ -897,7 +967,7 @@ async function getGitVCSPaths(vcs: GitVCS) { return Array.from(uniquePaths).sort(); } -async function getGitChanges(vcs: GitVCS, workspace: Workspace) { +async function getGitChanges(vcs: typeof GitVCS, workspace: Workspace) { // Cache status names const docs = await database.withDescendants(workspace); const allPaths = await getGitVCSPaths(vcs); @@ -909,7 +979,8 @@ async function getGitChanges(vcs: GitVCS, workspace: Workspace) { } // Create status items const items: Record = {}; - const log = (await vcs.log(1)) || []; + const log = (await vcs.log({ depth: 1 })) || []; + for (const gitPath of allPaths) { const status = await vcs.status(gitPath); if (status === 'unmodified') { @@ -977,14 +1048,12 @@ export const gitRollbackChangesAction: ActionFunction = async ({ params, request invariant(gitRepository, 'Git Repository not found'); - const vcs = getGitVCS(); - const formData = await request.formData(); const paths = [...formData.getAll('paths')] as string[]; const changeType = formData.get('changeType') as string; try { - const { changes } = await getGitChanges(vcs, workspace); + const { changes } = await getGitChanges(GitVCS, workspace); const files = changes .filter(i => changeType === 'modified' ? !i.status.includes('added') : i.status.includes('added')) @@ -997,7 +1066,7 @@ export const gitRollbackChangesAction: ActionFunction = async ({ params, request status: i.status, })); - await gitRollback(vcs, files); + await gitRollback(GitVCS, files); } catch (e) { const errorMessage = e instanceof Error ? e.message : 'Error while rolling back changes'; return {