diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 0d1e7ab37b9b..5b238081f388 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -47,8 +47,8 @@ jobs: csc_link_secret: DESIGNER_MAC_CSC_LINK csc_key_password_secret: DESIGNER_MAC_CSC_KEY_PASSWORD - os: windows-latest - csc_link_secret: DESIGNER_WINDOWS_CSC_LINK - csc_key_password_secret: DESIGNER_WINDOWS_CSC_KEY_PASSWORD + csc_link_secret: '' + csc_key_password_secret: '' - os: ubuntu-latest csc_link_secret: '' csc_key_password_secret: '' @@ -132,32 +132,15 @@ jobs: - name: Package dist app (Windows only) if: matrix.os == 'windows-latest' shell: bash - run: NODE_OPTIONS='--max_old_space_size=6144' npm run package:windows:dist -w insomnia - - - - name: Setup Insomnia version env var (Windows only) - if: matrix.os == 'windows-latest' - shell: pwsh # Use PowerShell shell run: | - $insomniaVersion = jq -r '.version' "./packages/insomnia/package.json" - $insoVersion = jq -r '.version' "./packages/insomnia-inso/package.json" - echo "INSOMNIA_VERSION=$insomniaVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - echo "INSO_VERSION=$insoVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - # code-signs the windows installer - - name: Code-sign Windows Installer artifact (Windows only) - if: matrix.os == 'windows-latest' - uses: sslcom/esigner-codesign@develop - with: - command: sign - username: ${{secrets.ES_USERNAME}} - password: ${{secrets.ES_PASSWORD}} - credential_id: ${{secrets.ES_CREDENTIAL_ID}} - totp_secret: ${{secrets.ES_TOTP_SECRET}} - file_path: ${{ env.CODESIGN_FILE_PATH}} - override: true + docker pull ghcr.io/sslcom/codesigner-win:latest + NODE_OPTIONS='--max_old_space_size=6144' npm run package:windows:dist -w insomnia env: - CODESIGN_FILE_PATH: packages/insomnia/dist/squirrel-windows/Insomnia.Core-${{ env.INSOMNIA_VERSION }}.exe + USERNAME: ${{secrets.ES_USERNAME}} + PASSWORD: ${{secrets.ES_PASSWORD}} + CREDENTIAL_ID: ${{secrets.ES_CREDENTIAL_ID}} + TOTP_SECRET: ${{secrets.ES_TOTP_SECRET}} + - name: Package inso run: | diff --git a/package-lock.json b/package-lock.json index 7fc018760689..7d67ad0a991d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23016,7 +23016,7 @@ } }, "packages/insomnia": { - "version": "10.1.0-beta.2", + "version": "10.1.0", "license": "Apache-2.0", "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", @@ -23169,7 +23169,7 @@ } }, "packages/insomnia-inso": { - "version": "10.1.0-beta.2", + "version": "10.1.0", "license": "Apache-2.0", "dependencies": { "@seald-io/nedb": "^4.0.4", @@ -23196,7 +23196,7 @@ } }, "packages/insomnia-sdk": { - "version": "10.1.0-beta.2", + "version": "10.1.0", "license": "Apache-2.0", "dependencies": { "@types/deep-equal": "^1.0.4", @@ -23248,7 +23248,7 @@ } }, "packages/insomnia-smoke-test": { - "version": "10.1.0-beta.2", + "version": "10.1.0", "license": "Apache-2.0", "devDependencies": { "@grpc/grpc-js": "^1.12.00", @@ -23276,7 +23276,7 @@ } }, "packages/insomnia-testing": { - "version": "10.1.0-beta.2", + "version": "10.1.0", "license": "Apache-2.0" } } diff --git a/packages/insomnia-inso/package.json b/packages/insomnia-inso/package.json index 1a813ac8ca8b..4d4e21ffb91c 100644 --- a/packages/insomnia-inso/package.json +++ b/packages/insomnia-inso/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "insomnia-inso", - "version": "10.1.0-beta.2", + "version": "10.1.0", "homepage": "https://insomnia.rest", "description": "A CLI for Insomnia - The Collaborative API Design Tool", "author": "Kong ", diff --git a/packages/insomnia-sdk/package.json b/packages/insomnia-sdk/package.json index 95bca24d4f6b..13c4bf52ca65 100644 --- a/packages/insomnia-sdk/package.json +++ b/packages/insomnia-sdk/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "insomnia-sdk", - "version": "10.1.0-beta.2", + "version": "10.1.0", "description": "", "main": "src/objects/index.ts", "types": "src/objects/index.ts", diff --git a/packages/insomnia-smoke-test/package.json b/packages/insomnia-smoke-test/package.json index 9c456794d1ad..88eb0e27d2df 100644 --- a/packages/insomnia-smoke-test/package.json +++ b/packages/insomnia-smoke-test/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/kong/insomnia/issues" }, - "version": "10.1.0-beta.2", + "version": "10.1.0", "scripts": { "test:dev": "xvfb-maybe cross-env BUNDLE=dev playwright test", "test:build": "xvfb-maybe cross-env BUNDLE=build playwright test", diff --git a/packages/insomnia-smoke-test/tests/critical/certificates.test.ts b/packages/insomnia-smoke-test/tests/critical/certificates.test.ts index 2e6144f03929..ab37852ed634 100644 --- a/packages/insomnia-smoke-test/tests/critical/certificates.test.ts +++ b/packages/insomnia-smoke-test/tests/critical/certificates.test.ts @@ -16,7 +16,6 @@ test('can send request with custom ca root certificate', async ({ app, page }) = await page.getByLabel('Request Collection').getByTestId('sends request with certs').press('Enter'); await page.getByRole('button', { name: 'Send', exact: true }).click(); - await page.getByRole('button', { name: 'Ok', exact: true }).click(); await page.getByText('Error: SSL peer certificate or SSH remote key was not OK').click(); const fixturePath = getFixturePath('certificates'); diff --git a/packages/insomnia-smoke-test/tests/smoke/app.test.ts b/packages/insomnia-smoke-test/tests/smoke/app.test.ts index be5a4f16019f..01b9e0ac2b6e 100644 --- a/packages/insomnia-smoke-test/tests/smoke/app.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/app.test.ts @@ -96,6 +96,5 @@ test('can cancel requests', async ({ app, page }) => { await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await page.getByRole('button', { name: 'Cancel Request' }).click(); - await page.getByRole('button', { name: 'Ok', exact: true }).click(); await page.click('text=Request was cancelled'); }); diff --git a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts index 20761685ad68..b1dff01aa71c 100644 --- a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts @@ -34,6 +34,14 @@ test.describe('Environment Editor', async () => { await page.getByText('baseenv1').click(); }); + test('duplicate an environment', async ({ page }) => { + await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByRole('button', { name: 'Manage collection environments' }).click(); + await page.getByRole('row', { name: 'ExampleA' }).getByLabel('Environment Actions').click(); + await page.getByText('Duplicate').click(); + await page.getByLabel('Environments', { exact: true }).getByText('ExampleA (Copy)').click(); + }); + // rename an existing environment test('Rename an existing environment', async ({ page }) => { // Rename the environment diff --git a/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts index da78e9832821..735b8ca67113 100644 --- a/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/git-interactions.test.ts @@ -42,14 +42,11 @@ test('Git Interactions (clone, checkout branch, pull, push, stage changes, ...)' await page.waitForTimeout(1000); await page.getByTestId('git-dropdown').click(); await page.getByText('Commit').click(); - await page.getByText('Modified Objects').click(); - await page.getByText('ApiSpec').click(); - await page.getByPlaceholder('A descriptive message to').click(); - await page.getByPlaceholder('A descriptive message to').fill('example commit message'); - await page.getByRole('dialog').getByText('abc').click(); - await page.getByRole('button', { name: ' Commit' }).click(); - await page.getByText('No changes to commit.').click(); - await page.getByRole('button', { name: 'Close' }).click(); + await page.getByRole('row', { name: 'spec.yaml' }).click(); + await page.locator('button[name="Stage all changes"]').click(); + await page.getByPlaceholder('This is a helpful message').click(); + await page.getByPlaceholder('This is a helpful message').fill('example commit message'); + await page.getByRole('button', { name: 'Commit', exact: true }).click(); // switch back to main branch, which should not have said changes await page.getByTestId('git-dropdown').click(); @@ -93,17 +90,21 @@ test('Git Interactions (clone, checkout branch, pull, push, stage changes, ...)' await page.getByLabel('Name', { exact: true }).fill(`My Folder ${testUUID}`); await page.getByRole('button', { name: 'Create', exact: true }).click(); await page.getByTestId('git-dropdown').click(); + + // Commit changes await page.getByText('Commit').click(); - await page.getByRole('cell', { name: `My Folder ${testUUID}` }).locator('label').click(); - await page.getByPlaceholder('A descriptive message to').click(); - await page.getByPlaceholder('A descriptive message to').fill(`commit test ${testUUID}`); - await page.getByText('Commit Changes').click(); - await page.getByRole('button', { name: ' Commit' }).click(); - await page.getByText('No changes to commit.').click(); - await page.getByRole('button', { name: 'Close' }).click(); + await page.getByRole('row', { name: `My Folder ${testUUID}`, exact: true }).click(); + await page.locator('button[name="Stage all changes"]').click(); + await page.getByPlaceholder('This is a helpful message').click(); + await page.getByPlaceholder('This is a helpful message').fill(`commit test ${testUUID}`); + await page.getByRole('button', { name: 'Commit', exact: true }).click(); + + // Push changes await page.getByTestId('git-dropdown').click(); await page.getByText('Push', { exact: true }).click(); await page.getByTestId('git-dropdown').click(); + + // Check if the changes are pushed await page.getByText('Fetch').click(); await page.getByTestId('git-dropdown').click(); await page.getByText('History').click(); diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts index f208d0dfec16..7768f789cb7c 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts @@ -368,10 +368,6 @@ test.describe('pre-request features tests', async () => { // send await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); - // close the alert modal - await page.getByRole('code').getByText('Error: Couldn\'t connect to').click(); - await page.getByRole('button', { name: 'Ok', exact: true }).click(); - // verify await page.getByRole('tab', { name: 'Console' }).click(); await expect(responsePane).toContainText('localhost:2222'); // original proxy diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-window.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-window.test.ts index 4855cb7dc3ed..a91871081d36 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-window.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-window.test.ts @@ -28,9 +28,6 @@ test.describe('test hidden window handling', async () => { await page.getByRole('button', { name: 'Cancel Request' }).click(); - // check the alert model message - await page.getByRole('code').getByText('Request was cancelled').click(); - await page.getByRole('button', { name: 'Ok', exact: true }).click(); // check the response pane message await page.click('text=Request was cancelled'); }); @@ -54,8 +51,7 @@ test.describe('test hidden window handling', async () => { await page.getByLabel('Request Collection').getByTestId('Long running task - post').press('Enter'); await page.getByTestId('request-pane').getByRole('button', { name: 'Send', exact: true }).click(); - await page.getByRole('code').getByText('Executing script timeout').click(); - await page.getByRole('button', { name: 'Ok', exact: true }).click(); + await page.getByText('Executing script timeout').click(); await page.getByRole('tab', { name: 'Console' }).click(); await page.getByRole('tab', { name: 'Preview' }).click(); @@ -98,8 +94,7 @@ test.describe('test hidden window handling', async () => { await page.getByTestId('request-pane').getByRole('button', { name: 'Send', exact: true }).click(); // await page.getByText('Timeout: Hidden browser window is not responding').click(); - await page.getByRole('code').getByText('Executing script timeout').click(); - await page.getByRole('button', { name: 'Ok', exact: true }).click(); + await page.getByText('Executing script timeout').click(); // send the another script with normal script await page.getByLabel('Request Collection').getByTestId('simple log').press('Enter'); diff --git a/packages/insomnia-testing/package.json b/packages/insomnia-testing/package.json index 409e2c21e4fa..7be03cc1f01d 100644 --- a/packages/insomnia-testing/package.json +++ b/packages/insomnia-testing/package.json @@ -2,7 +2,7 @@ "private": true, "name": "insomnia-testing", "license": "Apache-2.0", - "version": "10.1.0-beta.2", + "version": "10.1.0", "author": "Kong ", "repository": { "type": "git", diff --git a/packages/insomnia/customSign.js b/packages/insomnia/customSign.js new file mode 100644 index 000000000000..26b17687a5ee --- /dev/null +++ b/packages/insomnia/customSign.js @@ -0,0 +1,59 @@ +const { exec } = require('child_process'); +const util = require('util'); +const path = require('path'); +const execAsync = util.promisify(exec); + +// adapted from https://www.electron.build/win.html#how-do-delegate-code-signing +// It was possible code-sign installer after packaging, but some files are only available +// through hooking into the signing step of electron-builder while the final squirrel installer is being built +// This makes it possible to sign the Update.exe and stub of Insomnia.exe that end up in C:\Users\\AppData\Local\insomnia +exports.default = async function(configuration) { + // skip signing if not windows squirrel + if (configuration.options.target.length === 0 || configuration.options.target[0].target !== 'squirrel') { + console.log('[customSign] Skipping signing because target is not windows squirrel.'); + return; + } + + const { USERNAME, PASSWORD, CREDENTIAL_ID, TOTP_SECRET } = process.env; + if (!USERNAME || !PASSWORD || !CREDENTIAL_ID || !TOTP_SECRET) { + console.log('[customSign] Skipping signing, Missing required environment variables.'); + return; + } + + // Note: Avoid changing the lines bellow. Risk of breaking the windows code-signing process. + // Feedback loop > 15 mins. Requires a branch on origin, a PR, and a separate dummy release pipeline to test changes. + // sslcom/codesigner-win has large image size (>1GB) and requires docker within windows-latest host. + const rawPath = configuration.path.replace(/(\r\n|\n|\r)/gm, ''); // remove /n and other crap from path + console.log('[customSign] File to sign before final packaging:', rawPath); + const absolutePath = path.resolve(rawPath); // C:\Users\...\Update.exe + const fixedAbsolutePath = absolutePath.replace(/\\/g, '/'); // C:/Users/.../Update.exe + const lastSlashIndex = fixedAbsolutePath.lastIndexOf('/'); // index of last / slash + const directoryPath = fixedAbsolutePath.substring(0, lastSlashIndex); // C:/Users/... + const inputFileName = path.basename(absolutePath); // Update.exe + const codeSignPath = 'C:/CodeSignTool/Insomnia'; // path inside docker container + const dockerInputFilePath = path.join(codeSignPath, inputFileName); // C:/CodeSignTool/Insomnia/Update.exe + const dockerCommand = `docker run --rm \ + -v "${directoryPath}:${codeSignPath}" \ + -e USERNAME="${USERNAME}" \ + -e PASSWORD="${PASSWORD}" \ + -e CREDENTIAL_ID="${CREDENTIAL_ID}" \ + -e TOTP_SECRET="${TOTP_SECRET}" \ + ghcr.io/sslcom/codesigner-win:latest sign \ + \`\`-input_file_path="${dockerInputFilePath}" \`\`-override`; + + try { + console.log('[customSign] Docker command:', dockerCommand); + console.log('[customSign] Starting to run sign cmd via docker...'); + const { stdout, stderr } = await execAsync(dockerCommand); + + console.log('[customSign] Docker command output:', stdout); + if (stderr) { + console.error('[customSign] Docker command error output:', stderr); + } + + console.log('[customSign] File signed successfully.'); + } catch (error) { + console.error('[customSign] Error executing Docker command:', error); + throw error; + } +}; diff --git a/packages/insomnia/electron-builder.config.js b/packages/insomnia/electron-builder.config.js index 54a6e39b44a7..9e9eae558b0c 100644 --- a/packages/insomnia/electron-builder.config.js +++ b/packages/insomnia/electron-builder.config.js @@ -89,6 +89,8 @@ const config = { target: 'squirrel', }, ], + sign: './customSign.js', + signingHashAlgorithms: ['sha256'], // avoid duplicate signing hook calls https://github.com/electron-userland/electron-builder/issues/3995#issuecomment-505725704 }, squirrelWindows: { artifactName: `${BINARY_PREFIX}-\${version}.\${ext}`, diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 46722e05dc03..a71a1089c735 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -1,6 +1,6 @@ { "name": "insomnia", - "version": "10.1.0-beta.2", + "version": "10.1.0", "productName": "Insomnia", "private": true, "description": "The Collaborative API Design Tool", @@ -23,7 +23,7 @@ "lint": "eslint . --ext .js,.ts,.tsx --cache", "package": "npm run build:app && cross-env USE_HARD_LINKS=false electron-builder build --config electron-builder.config.js", "package:windows:unpacked": "npm run build:app && cross-env USE_HARD_LINKS=false electron-builder build --config electron-builder.config.js --dir", - "package:windows:dist": "cross-env USE_HARD_LINKS=false electron-builder build --config electron-builder.config.js --prepackaged ./dist/win-unpacked", + "package:windows:dist": "cross-env USE_HARD_LINKS=false electron-builder build --config electron-builder.config.js --win squirrel --prepackaged ./dist/win-unpacked", "start": "npx -y concurrently -n browser,main --kill-others \"npm run start:dev-server\" \"npm run start:electron\"", "start:dev-server": "vite dev", "start:electron": "cross-env NODE_ENV=development esr esbuild.main.ts && electron --inspect=5858 .", diff --git a/packages/insomnia/src/common/import.ts b/packages/insomnia/src/common/import.ts index 96fa7daf3df9..09b8ac54c873 100644 --- a/packages/insomnia/src/common/import.ts +++ b/packages/insomnia/src/common/import.ts @@ -281,13 +281,16 @@ export const importResourcesToWorkspace = async ({ workspaceId }: { workspaceId: const baseEnvironment = await models.environment.getOrCreateForParentId(workspaceId); invariant(baseEnvironment, 'Could not create base environment'); + const baseEnvironmentFromResources = resources.filter(isEnvironment).find(env => env.parentId && env.parentId.startsWith('__WORKSPACE_ID__')); + if (baseEnvironmentFromResources) { + await models.environment.update(baseEnvironment, { data: baseEnvironmentFromResources.data }); + } const subEnvironments = resources.filter(isEnvironment).filter(isSubEnvironmentResource) || []; for (const environment of subEnvironments) { const model = getModel(environment.type); model && ResourceIdMap.set(environment._id, generateId(model.prefix)); - - await db.docCreate(environment.type, { + await models.environment.create({ ...environment, _id: ResourceIdMap.get(environment._id), parentId: baseEnvironment._id, @@ -307,7 +310,7 @@ export const importResourcesToWorkspace = async ({ workspaceId }: { workspaceId: if (model) { // Make sure we point to the new proto file if (isGrpcRequest(resource)) { - await db.docCreate(model.type, { + await models.grpcRequest.create({ ...resource, _id: ResourceIdMap.get(resource._id), protoFileId: ResourceIdMap.get(resource.protoFileId), @@ -316,14 +319,14 @@ export const importResourcesToWorkspace = async ({ workspaceId }: { workspaceId: // Make sure we point unit test to the new request } else if (isUnitTest(resource)) { - await db.docCreate(model.type, { + await models.unitTest.create({ ...resource, _id: ResourceIdMap.get(resource._id), requestId: ResourceIdMap.get(resource.requestId), parentId: ResourceIdMap.get(resource.parentId), }); } else if (isRequest(resource)) { - await db.docCreate(model.type, importRequestWithNewIds(resource, ResourceIdMap, canTransform)); + await models.request.create(importRequestWithNewIds(resource, ResourceIdMap, canTransform)); } else { await db.docCreate(model.type, { ...resource, @@ -413,21 +416,21 @@ const importResourcesToNewWorkspace = async (projectId: string, workspaceToImpor if (model) { if (isGrpcRequest(resource)) { - await db.docCreate(model.type, { + await models.grpcRequest.create({ ...resource, _id: ResourceIdMap.get(resource._id), protoFileId: ResourceIdMap.get(resource.protoFileId), parentId: ResourceIdMap.get(resource.parentId), }); } else if (isUnitTest(resource)) { - await db.docCreate(model.type, { + await models.unitTest.create({ ...resource, _id: ResourceIdMap.get(resource._id), requestId: ResourceIdMap.get(resource.requestId), parentId: ResourceIdMap.get(resource.parentId), }); } else if (isRequest(resource)) { - await db.docCreate(model.type, importRequestWithNewIds(resource, ResourceIdMap, canTransform)); + await models.request.create(importRequestWithNewIds(resource, ResourceIdMap, canTransform)); } else { await db.docCreate(model.type, { ...resource, @@ -438,9 +441,8 @@ const importResourcesToNewWorkspace = async (projectId: string, workspaceToImpor } } - // Use the first environment as the active one - const subEnvironments = - resources.filter(isEnvironment).filter(isSubEnvironmentResource) || []; + // Use the first sub environment as the active one + const subEnvironments = resources.filter(isEnvironment).filter(isSubEnvironmentResource) || []; if (subEnvironments.length > 0) { const firstSubEnvironment = subEnvironments[0]; diff --git a/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts b/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts deleted file mode 100644 index 8955acc39fbe..000000000000 --- a/packages/insomnia/src/sync/git/__tests__/git-rollback.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import path from 'path'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { FileWithStatus } from '../git-rollback'; -import { gitRollback } from '../git-rollback'; -import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR } from '../git-vcs'; -import { MemClient } from '../mem-client'; -import { setupDateMocks } from './util'; - -describe('git rollback', () => { - describe('mocked', () => { - const removeMock = vi.fn().mockResolvedValue(undefined); - const unlinkMock = vi.fn().mockResolvedValue(undefined); - const undoPendingChangesMock = vi.fn().mockResolvedValue(undefined); - - let vcs: Partial = {}; - - beforeEach(() => { - vi.resetAllMocks(); - const fsMock = { - promises: { - unlink: unlinkMock, - }, - }; - vcs = { - getFs: vi.fn().mockReturnValue(fsMock), - remove: removeMock, - undoPendingChanges: undoPendingChangesMock, - }; - }); - - it('should remove and delete added and *added files', async () => { - const aTxt = 'a.txt'; - const bTxt = 'b.txt'; - const files: FileWithStatus[] = [ - { - filePath: aTxt, - status: 'added', - }, - { - filePath: bTxt, - status: '*added', - }, - ]; - await gitRollback(vcs, files); - expect(unlinkMock).toHaveBeenCalledTimes(2); - expect(unlinkMock).toHaveBeenNthCalledWith(1, aTxt); - expect(unlinkMock).toHaveBeenNthCalledWith(2, bTxt); - expect(removeMock).toHaveBeenCalledTimes(2); - expect(removeMock).toHaveBeenNthCalledWith(1, aTxt); - expect(removeMock).toHaveBeenNthCalledWith(2, bTxt); - expect(undoPendingChangesMock).not.toHaveBeenCalled(); - }); - - it('should undo pending changes for non-added files', async () => { - const aTxt = 'a.txt'; - const bTxt = 'b.txt'; - const files: FileWithStatus[] = [ - { - filePath: aTxt, - status: 'modified', - }, - { - filePath: bTxt, - status: 'deleted', - }, - ]; - await gitRollback(vcs, files); - expect(unlinkMock).toHaveBeenCalledTimes(0); - expect(removeMock).toHaveBeenCalledTimes(0); - expect(undoPendingChangesMock).toHaveBeenCalledTimes(1); - expect(undoPendingChangesMock).toHaveBeenCalledWith(expect.arrayContaining([aTxt, bTxt])); - }); - - it('should remove, delete, and undo appropriately depending on status', async () => { - const aTxt = 'a.txt'; - const bTxt = 'b.txt'; - const cTxt = 'c.txt'; - const dTxt = 'd.txt'; - const files: FileWithStatus[] = [ - { - filePath: aTxt, - status: 'added', - }, - { - filePath: bTxt, - status: '*added', - }, - { - filePath: cTxt, - status: 'modified', - }, - { - filePath: dTxt, - status: 'deleted', - }, - ]; - await gitRollback(vcs, files); - expect(unlinkMock).toHaveBeenCalledTimes(2); - expect(unlinkMock).toHaveBeenNthCalledWith(1, aTxt); - expect(unlinkMock).toHaveBeenNthCalledWith(2, bTxt); - expect(removeMock).toHaveBeenCalledTimes(2); - expect(removeMock).toHaveBeenNthCalledWith(1, aTxt); - expect(removeMock).toHaveBeenNthCalledWith(2, bTxt); - expect(undoPendingChangesMock).toHaveBeenCalledTimes(1); - expect(undoPendingChangesMock).toHaveBeenCalledWith(expect.arrayContaining([cTxt, dTxt])); - }); - }); - - describe('integration', () => { - let fooTxt = ''; - let barTxt = ''; - let bazTxt = ''; - beforeAll(() => { - fooTxt = path.join(GIT_INSOMNIA_DIR, 'foo.txt'); - barTxt = path.join(GIT_INSOMNIA_DIR, 'bar.txt'); - bazTxt = path.join(GIT_INSOMNIA_DIR, 'baz.txt'); - }); - afterAll(() => vi.restoreAllMocks()); - beforeEach(setupDateMocks); - - it('should rollback files as expected', async () => { - const originalContent = 'original'; - const fsClient = MemClient.createClient(); - await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); - await fsClient.promises.writeFile(fooTxt, 'foo'); - await fsClient.promises.writeFile(barTxt, 'bar'); - await fsClient.promises.writeFile(bazTxt, originalContent); - const vcs = GitVCS; - await vcs.init({ - uri: '', - repoId: '', - directory: GIT_CLONE_DIR, - fs: fsClient, - }); - // Commit - await vcs.setAuthor('Karen Brown', 'karen@example.com'); - await vcs.add(bazTxt); - await vcs.commit('First commit!'); - // Edit file - await fsClient.promises.writeFile(bazTxt, 'changedContent'); - // foo is staged, bar is unstaged, but both are untracked (thus, new to git) - await vcs.add(`${GIT_INSOMNIA_DIR}/bar.txt`); - const fooStatus = await vcs.status(fooTxt); - const barStatus = await vcs.status(barTxt); - const bazStatus = await vcs.status(bazTxt); - expect(fooStatus).toBe('*added'); - expect(barStatus).toBe('added'); - expect(bazStatus).toBe('*modified'); - const files: FileWithStatus[] = [ - { - filePath: fooTxt, - status: fooStatus, - }, - { - filePath: barTxt, - status: barStatus, - }, - { - filePath: bazTxt, - status: bazStatus, - }, - ]; - // Remove both - await gitRollback(vcs, files); - // Ensure git doesn't know about the two files anymore - expect(await vcs.status(fooTxt)).toBe('absent'); - expect(await vcs.status(barTxt)).toBe('absent'); - expect(await vcs.status(bazTxt)).toBe('unmodified'); - // Ensure the two files have been removed from the fs (memClient) - await expect(fsClient.promises.readFile(fooTxt)).rejects.toThrowError( - `ENOENT: no such file or directory, scandir '${fooTxt}'`, - ); - await expect(fsClient.promises.readFile(barTxt)).rejects.toThrowError( - `ENOENT: no such file or directory, scandir '${barTxt}'`, - ); - expect((await fsClient.promises.readFile(bazTxt)).toString()).toBe(originalContent); - }); - }); -}); 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 4c6798340f3f..01a4989954e6 100644 --- a/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts @@ -1,19 +1,14 @@ import * as git from 'isomorphic-git'; import path from 'path'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR } from '../git-vcs'; import { MemClient } from '../mem-client'; import { setupDateMocks } from './util'; describe('Git-VCS', () => { - let fooTxt = ''; - let barTxt = ''; - - beforeAll(() => { - fooTxt = path.join(GIT_INSOMNIA_DIR, 'foo.txt'); - barTxt = path.join(GIT_INSOMNIA_DIR, 'bar.txt'); - }); + const fooTxt = 'foo.txt'; + const barTxt = 'bar.txt'; afterAll(() => { vi.restoreAllMocks(); @@ -22,31 +17,12 @@ describe('Git-VCS', () => { beforeEach(setupDateMocks); describe('common operations', () => { - it('listFiles()', async () => { - const fsClient = MemClient.createClient(); - - await GitVCS.init({ - uri: '', - repoId: '', - directory: GIT_CLONE_DIR, - fs: fsClient, - }); - await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); - // No files exist yet - 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 GitVCS.listFiles(); - expect(files2).toEqual([]); - }); - it('stage and unstage file', async () => { + // Write the files to the repository directory const fsClient = MemClient.createClient(); await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); - await fsClient.promises.writeFile(fooTxt, 'foo'); - await fsClient.promises.writeFile(barTxt, 'bar'); - // Files outside namespace should be ignored + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, barTxt), 'bar'); await fsClient.promises.writeFile('/other.txt', 'other'); await GitVCS.init({ @@ -56,14 +32,88 @@ describe('Git-VCS', () => { fs: fsClient, }); 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'); + + // foo.txt and bar.txt should be in the unstaged list + const status = await GitVCS.status(); + expect(status.staged).toEqual([]); + expect(status.unstaged).toEqual([{ + 'name': '', + 'path': '.insomnia/bar.txt', + 'status': [0, 2, 0], + }, + { + 'name': '', + 'path': '.insomnia/foo.txt', + 'status': [0, 2, 0], + }, + { + 'name': '', + 'path': 'other.txt', + 'status': [0, 2, 0], + }, + ]); + + const fooStatus = status.unstaged.find(f => f.path.includes(fooTxt)); + + fooStatus && await GitVCS.stageChanges([fooStatus]); + const status2 = await GitVCS.status(); + expect(status2.staged).toEqual([{ + 'name': '', + 'path': '.insomnia/foo.txt', + 'status': [0, 2, 2], + }]); + expect(status2.unstaged).toEqual([ + { + 'name': '', + 'path': '.insomnia/bar.txt', + 'status': [0, 2, 0], + }, + { + 'name': '', + 'path': 'other.txt', + 'status': [0, 2, 0], + }, + ]); + + const barStatus = status2.unstaged.find(f => f.path.includes(barTxt)); + + barStatus && await GitVCS.stageChanges([barStatus]); + const status3 = await GitVCS.status(); + expect(status3.staged).toEqual([ + { + 'name': '', + 'path': '.insomnia/bar.txt', + 'status': [0, 2, 2], + }, + { + 'name': '', + 'path': '.insomnia/foo.txt', + 'status': [0, 2, 2], + }, + ]); + + const fooStatus3 = status3.staged.find(f => f.path.includes(fooTxt)); + fooStatus3 && await GitVCS.unstageChanges([fooStatus3]); + const status4 = await GitVCS.status(); + expect(status4).toEqual({ + staged: [{ + 'name': '', + 'path': '.insomnia/bar.txt', + 'status': [0, 2, 2], + }], + unstaged: [ + { + 'name': '', + 'path': '.insomnia/foo.txt', + 'status': [0, 2, 0], + }, + { + 'name': '', + 'path': 'other.txt', + 'status': [0, 2, 0], + }, + ], + }); }); it('Returns empty log without first commit', async () => { @@ -82,8 +132,8 @@ describe('Git-VCS', () => { it('commit file', async () => { const fsClient = MemClient.createClient(); await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); - await fsClient.promises.writeFile(fooTxt, 'foo'); - await fsClient.promises.writeFile(barTxt, 'bar'); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, barTxt), 'bar'); await fsClient.promises.writeFile('other.txt', 'should be ignored'); await GitVCS.init({ @@ -92,11 +142,67 @@ describe('Git-VCS', () => { directory: GIT_CLONE_DIR, fs: fsClient, }); + await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); - await GitVCS.add(fooTxt); + + const status = await GitVCS.status(); + const fooStatus = status.unstaged.find(f => f.path.includes(fooTxt)); + fooStatus && await GitVCS.stageChanges([fooStatus]); + + const status2 = await GitVCS.status(); + + expect(status2.staged).toEqual([{ + 'name': '', + 'path': '.insomnia/foo.txt', + 'status': [0, 2, 2], + }]); + expect(status2.unstaged).toEqual([ + { + 'name': '', + 'path': '.insomnia/bar.txt', + 'status': [ + 0, + 2, + 0, + ], + }, + { + 'name': '', + 'path': 'other.txt', + 'status': [ + 0, + 2, + 0, + ], + }, + ]); + await GitVCS.commit('First commit!'); - expect(await GitVCS.status(barTxt)).toBe('*added'); - expect(await GitVCS.status(fooTxt)).toBe('unmodified'); + + const status3 = await GitVCS.status(); + + expect(status3.staged).toEqual([]); + expect(status3.unstaged).toEqual([ + { + 'name': '', + 'path': '.insomnia/bar.txt', + 'status': [ + 0, + 2, + 0, + ], + }, + { + 'name': '', + 'path': 'other.txt', + 'status': [ + 0, + 2, + 0, + ], + }, + ]); + expect(await GitVCS.log()).toEqual([ { commit: { @@ -125,15 +231,7 @@ First commit! `, }, ]); - await fsClient.promises.unlink(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'); - await GitVCS.remove(fooTxt); - expect(await GitVCS.status(barTxt)).toBe('*added'); - expect(await GitVCS.status(fooTxt)).toBe('deleted'); + await fsClient.promises.unlink(path.join(GIT_INSOMNIA_DIR, fooTxt)); }); it('create branch', async () => { @@ -149,12 +247,16 @@ First commit! fs: fsClient, }); await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); - await GitVCS.add(fooTxt); + const status = await GitVCS.status(); + const fooStatus = status.unstaged.find(f => f.path.includes(fooTxt)); + fooStatus && await GitVCS.stageChanges([fooStatus]); 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); + const status2 = await GitVCS.status(); + const barStatus = status2.unstaged.find(f => f.path.includes(barTxt)); + barStatus && await GitVCS.stageChanges([barStatus]); await GitVCS.commit('Second commit!'); expect((await GitVCS.log()).length).toBe(2); await GitVCS.checkout('main'); @@ -164,6 +266,7 @@ First commit! describe('push()', () => { it('should throw an exception when push response contains errors', async () => { + // @ts-expect-error -- mockReturnValue is not typed git.push.mockReturnValue({ ok: ['unpack'], errors: ['refs/heads/master pre-receive hook declined'], @@ -194,19 +297,49 @@ First commit! }); // Commit await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); - await GitVCS.add(fooTxt); - await GitVCS.add(folderBarTxt); + + const status = await GitVCS.status(); + + const fooStatus = status.unstaged.find(s => s.path.includes(fooTxt)); + const folderStatus = status.unstaged.find(s => s.path.includes(folder)); + + if (!fooStatus || !folderStatus) { + throw new Error('c'); + } + + await GitVCS.stageChanges([fooStatus, folderStatus]); await GitVCS.commit('First commit!'); // Change the file - await fsClient.promises.writeFile(fooTxt, 'changedContent'); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'changedContent'); await fsClient.promises.writeFile(folderBarTxt, 'changedContent'); - expect(await GitVCS.status(fooTxt)).toBe('*modified'); - expect(await GitVCS.status(folderBarTxt)).toBe('*modified'); - // Undo - await GitVCS.undoPendingChanges(); + + const status2 = await GitVCS.status(); + + expect(status2).toEqual({ + 'staged': [], + 'unstaged': [ + { + 'name': '', + 'path': '.insomnia/folder/bar.txt', + 'status': [1, 2, 1], + }, + { + 'name': '', + 'path': '.insomnia/foo.txt', + 'status': [0, 2, 0], + }, + ], + }); + // Discard changes + await GitVCS.discardChanges(status2.unstaged); + + const status3 = await GitVCS.status(); + // Ensure git doesn't recognize a change anymore - expect(await GitVCS.status(fooTxt)).toBe('unmodified'); - expect(await GitVCS.status(folderBarTxt)).toBe('unmodified'); + expect(status3).toEqual({ + staged: [], + unstaged: [], + }); // Expect original doc to have reverted expect((await fsClient.promises.readFile(fooTxt)).toString()).toBe(originalContent); expect((await fsClient.promises.readFile(folderBarTxt)).toString()).toBe(originalContent); @@ -232,51 +365,31 @@ First commit! await Promise.all(files.map(f => fsClient.promises.writeFile(f, originalContent))); // Commit all files await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); - await Promise.all(files.map(f => GitVCS.add(f, originalContent))); + const status = await GitVCS.status(); + await GitVCS.stageChanges(status.unstaged); 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(GitVCS.status(foo1Txt)).resolves.toBe('*modified'))); + + const status2 = await GitVCS.status(); // Undo foo1 and foo2, but not foo3 - await GitVCS.undoPendingChanges([foo1Txt, foo2Txt]); - expect(await GitVCS.status(foo1Txt)).toBe('unmodified'); - expect(await GitVCS.status(foo2Txt)).toBe('unmodified'); + const changesToUndo = status2.unstaged.filter(change => !change.path.includes(foo3Txt)); + await GitVCS.discardChanges(changesToUndo); + const status3 = await GitVCS.status(); + expect(status3).toEqual({ + 'staged': [], + 'unstaged': [ + { + 'name': '', + 'path': '.insomnia/foo3.txt', + 'status': [1, 2, 1], + }, + ], + }); // 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 GitVCS.status(foo3Txt)).toBe('*modified'); expect((await fsClient.promises.readFile(foo3Txt)).toString()).toBe(changedContent); }); }); - - describe('readObjectFromTree()', () => { - it('reads an object from tree', async () => { - const fsClient = MemClient.createClient(); - const dir = path.join(GIT_INSOMNIA_DIR, 'dir'); - const dirFooTxt = path.join(dir, 'foo.txt'); - await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); - await fsClient.promises.mkdir(dir); - await fsClient.promises.writeFile(dirFooTxt, 'foo'); - - await GitVCS.init({ - uri: '', - repoId: '', - directory: GIT_CLONE_DIR, - fs: fsClient, - }); - await GitVCS.setAuthor('Karen Brown', 'karen@example.com'); - await GitVCS.add(dirFooTxt); - await GitVCS.commit('First'); - await fsClient.promises.writeFile(dirFooTxt, 'foo bar'); - 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 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-rollback.ts b/packages/insomnia/src/sync/git/git-rollback.ts deleted file mode 100644 index f026eb0ee79f..000000000000 --- a/packages/insomnia/src/sync/git/git-rollback.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { GitVCS } from './git-vcs'; - -export interface FileWithStatus { - filePath: string; - status: string; -} - -const isAdded = ({ status }: FileWithStatus) => status.includes('added'); - -const isNotAdded = ({ status }: FileWithStatus) => !status.includes('added'); - -export const gitRollback = async (vcs: GitVCS, files: FileWithStatus[]) => { - const addedFiles = files.filter(isAdded); - // Remove and delete added (unversioned) files - const promises = addedFiles.map(async ({ filePath }) => { - await vcs.remove(filePath); - console.log(`[git-rollback] Delete relPath=${filePath}`); - // @ts-expect-error -- TSCONVERSION - await vcs.getFs().promises.unlink(filePath); - }); - // Rollback existing (versioned) files - const existingFiles = files.filter(isNotAdded).map(f => f.filePath); - - if (existingFiles.length) { - promises.push(vcs.undoPendingChanges(existingFiles)); - } - - await Promise.all(promises); -}; diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 084ef1ed0a76..66cbe09fee13 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -1,8 +1,9 @@ import * as git from 'isomorphic-git'; import path from 'path'; +import { parse } from 'yaml'; import { httpClient } from './http-client'; -import { convertToOsSep, convertToPosixSep } from './path-sep'; +import { convertToPosixSep } from './path-sep'; import { gitCallbacks } from './utils'; export interface GitAuthor { @@ -88,8 +89,22 @@ interface InitFromCloneOptions { export const GIT_CLONE_DIR = '.'; const gitInternalDirName = 'git'; export const GIT_INSOMNIA_DIR_NAME = '.insomnia'; -export const GIT_INTERNAL_DIR = path.join(GIT_CLONE_DIR, gitInternalDirName); -export const GIT_INSOMNIA_DIR = path.join(GIT_CLONE_DIR, GIT_INSOMNIA_DIR_NAME); +export const GIT_INTERNAL_DIR = path.join(GIT_CLONE_DIR, gitInternalDirName); // .git +export const GIT_INSOMNIA_DIR = path.join(GIT_CLONE_DIR, GIT_INSOMNIA_DIR_NAME); // .insomnia + +function getInsomniaFileName(blob: void | Uint8Array | undefined): string { + if (!blob) { + return ''; + } + + try { + const parsed = parse(Buffer.from(blob).toString('utf-8')); + return parsed?.fileName || parsed?.name || ''; + } catch (e) { + // If the document couldn't be parsed as yaml return an empty string + return ''; + } +} interface BaseOpts { dir: string; @@ -192,15 +207,6 @@ export class GitVCS { 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); - return insomniaFiles; - } - async getCurrentBranch() { const branch = await git.currentBranch({ ...this._baseOpts }); @@ -257,23 +263,266 @@ export class GitVCS { } } - async status(filepath: string) { - return git.status({ - ...this._baseOpts, - filepath: convertToPosixSep(filepath), + async fileStatus(file: string) { + const baseOpts = this._baseOpts; + // Adopted from statusMatrix of isomorphic-git https://github.com/isomorphic-git/isomorphic-git/blob/main/src/api/statusMatrix.js#L157 + const [blobs]: [[string, string, string, string]] = await git.walk({ + ...baseOpts, + trees: [git.TREE({ ref: 'HEAD' }), git.WORKDIR(), git.STAGE()], + map: async function map(filepath, [head, workdir, stage]) { + // Late filter against file names + if (filepath !== file) { + return; + } + + const [headType, workdirType, stageType] = await Promise.all([ + head && head.type(), + workdir && workdir.type(), + stage && stage.type(), + ]); + + const isBlob = [headType, workdirType, stageType].includes('blob'); + + // For now, bail on directories unless the file is also a blob in another tree + if ((headType === 'tree' || headType === 'special') && !isBlob) { + return; + } + if (headType === 'commit') { + return null; + } + + if ((workdirType === 'tree' || workdirType === 'special') && !isBlob) { + return; + } + + if (stageType === 'commit') { + return null; + } + if ((stageType === 'tree' || stageType === 'special') && !isBlob) { + return; + } + + // Figure out the oids for files, using the staged oid for the working dir oid if the stats match. + const headOid = headType === 'blob' ? await head?.oid() : undefined; + const stageOid = stageType === 'blob' ? await stage?.oid() : undefined; + let workdirOid; + if ( + headType !== 'blob' && + workdirType === 'blob' && + stageType !== 'blob' + ) { + workdirOid = '42'; + } else if (workdirType === 'blob') { + workdirOid = await workdir?.oid(); + } + + let headBlob = await head?.content(); + let workdirBlob = await workdir?.content(); + let stageBlob = await stage?.content(); + + if (!stageBlob && stageOid) { + try { + const { blob } = await git.readBlob({ + ...baseOpts, + + oid: stageOid, + }); + + stageBlob = blob; + } catch (e) { + console.log('[git] Failed to read blob', e); + } + } + + if (!headBlob && headOid) { + try { + const { blob } = await git.readBlob({ + ...baseOpts, + + oid: headOid, + }); + + headBlob = blob; + } catch (e) { + console.log('[git] Failed to read blob', e); + } + } + + if (!workdirBlob && workdirOid) { + try { + const { blob } = await git.readBlob({ + ...baseOpts, + + oid: workdirOid, + }); + + workdirBlob = blob; + } catch (e) { + console.log('[git] Failed to read blob', e); + } + } + + const blobsAsJSONStrings = [headBlob, workdirBlob, stageBlob].map(blob => { + if (!blob) { + return null; + } + + try { + return JSON.stringify(parse(Buffer.from(blob).toString('utf-8'))); + } catch (e) { + return null; + } + }); + + return [filepath, ...blobsAsJSONStrings]; + }, }); - } - async add(relPath: string) { - relPath = convertToPosixSep(relPath); - console.log(`[git] Add ${relPath}`); - return git.add({ ...this._baseOpts, filepath: relPath }); + const diff = { + head: blobs[1], + workdir: blobs[2], + stage: blobs[3], + }; + + return diff; + } + + async statusWithContent() { + const baseOpts = this._baseOpts; + + // Adopted from statusMatrix of isomorphic-git https://github.com/isomorphic-git/isomorphic-git/blob/main/src/api/statusMatrix.js#L157 + const status: { + filepath: string; + head: { name: string; status: git.HeadStatus }; + workdir: { name: string; status: git.WorkdirStatus }; + stage: { name: string; status: git.StageStatus }; + }[] = await git.walk({ + ...baseOpts, + trees: [ + // What the latest commit on the current branch looks like + git.TREE({ ref: 'HEAD' }), + // What the working directory looks like + git.WORKDIR(), + // What the index (staging area) looks like + git.STAGE(), + ], + map: async function map(filepath, [head, workdir, stage]) { + if (await git.isIgnored({ + ...baseOpts, + filepath, + })) { + return null; + } + const [headType, workdirType, stageType] = await Promise.all([ + head && head.type(), + workdir && workdir.type(), + stage && stage.type(), + ]); + + const isBlob = [headType, workdirType, stageType].includes('blob'); + + // For now, bail on directories unless the file is also a blob in another tree + if ((headType === 'tree' || headType === 'special') && !isBlob) { + return; + } + if (headType === 'commit') { + return null; + } + + if ((workdirType === 'tree' || workdirType === 'special') && !isBlob) { + return; + } + + if (stageType === 'commit') { + return null; + } + if ((stageType === 'tree' || stageType === 'special') && !isBlob) { + return; + } + + // Figure out the oids for files, using the staged oid for the working dir oid if the stats match. + const headOid = headType === 'blob' ? await head?.oid() : undefined; + const stageOid = stageType === 'blob' ? await stage?.oid() : undefined; + let workdirOid; + if ( + headType !== 'blob' && + workdirType === 'blob' && + stageType !== 'blob' + ) { + // We don't actually NEED the sha. Any sha will do + // TODO: update this logic to handle N trees instead of just 3. + workdirOid = '42'; + } else if (workdirType === 'blob') { + workdirOid = await workdir?.oid(); + } + + const headBlob = await head?.content(); + const workdirBlob = await workdir?.content(); + let stageBlob = await stage?.content(); + + if (!stageBlob && stageOid) { + try { + const { blob } = await git.readBlob({ + ...baseOpts, + + oid: stageOid, + }); + + stageBlob = blob; + } catch (e) { + console.log('[git] Failed to read blob', e); + } + } + + // Adopted from isomorphic-git statusMatrix. + // This is needed to return the same status code numbers as isomorphic-git + // In isomorphic-git it can be found in these types: git.HeadStatus, git.WorkdirStatus, and git.StageStatus + const entry = [undefined, headOid, workdirOid, stageOid]; + const result = entry.map(value => entry.indexOf(value)); + result.shift(); // remove leading undefined entry + + return { + filepath, + head: { + name: getInsomniaFileName(headBlob), + status: result[0], + }, + workdir: { + name: getInsomniaFileName(workdirBlob), + status: result[1], + }, + stage: { + name: getInsomniaFileName(stageBlob), + status: result[2], + }, + }; + }, + }); + + return status; } - async remove(relPath: string) { - relPath = convertToPosixSep(relPath); - console.log(`[git] Remove relPath=${relPath}`); - return git.remove({ ...this._baseOpts, filepath: relPath }); + async status(): Promise<{ + staged: { path: string; status: [git.HeadStatus, git.WorkdirStatus, git.StageStatus]; name: string }[]; + unstaged: { path: string; status: [git.HeadStatus, git.WorkdirStatus, git.StageStatus]; name: string }[]; + }> { + const status = await this.statusWithContent(); + + const unstagedChanges = status.filter(({ workdir, stage }) => stage.status !== workdir.status); + const stagedChanges = status.filter(({ head, workdir, stage }) => head.status !== workdir.status && stage.status !== head.status && stage.status !== 0); + + return { + staged: stagedChanges.map(({ filepath, head, workdir, stage }) => ({ + path: filepath, + status: [head.status, workdir.status, stage.status], + name: stage.name || head.name || workdir.name || '', + })), + unstaged: unstagedChanges.map(({ filepath, head, workdir, stage }) => ({ + path: filepath, + status: [head.status, workdir.status, stage.status], + name: workdir.name || stage.name || head.name || '', + })), + }; } async addRemote(url: string) { @@ -297,18 +546,6 @@ export class GitVCS { return git.listRemotes({ ...this._baseOpts }); } - async getAuthor() { - const name = await git.getConfig({ ...this._baseOpts, path: 'user.name' }); - const email = await git.getConfig({ - ...this._baseOpts, - path: 'user.email', - }); - return { - name: name || '', - email: email || '', - } as GitAuthor; - } - async setAuthor(name: string, email: string) { await git.setConfig({ ...this._baseOpts, path: 'user.name', value: name }); await git.setConfig({ @@ -512,31 +749,6 @@ export class GitVCS { } } - async undoPendingChanges(fileFilter?: string[]) { - console.log('[git] Undo pending changes'); - await git.checkout({ - ...this._baseOpts, - ref: await this.getCurrentBranch(), - remote: 'origin', - force: true, - filepaths: fileFilter?.map(convertToPosixSep), - }); - } - - async readObjFromTree(treeOid: string, objPath: string) { - try { - const obj = await git.readObject({ - ...this._baseOpts, - oid: treeOid, - filepath: convertToPosixSep(objPath), - encoding: 'utf8', - }); - return obj.object; - } catch (err) { - return null; - } - } - async repoExists() { try { await git.getConfig({ ...this._baseOpts, path: '' }); @@ -547,8 +759,50 @@ export class GitVCS { return true; } - getFs() { - return this._baseOpts.fs; + async stageChanges(changes: { path: string; status: [git.HeadStatus, git.WorkdirStatus, git.StageStatus] }[]) { + for (const change of changes) { + console.log(`[git] Stage ${change.path} | ${change.status}`); + if (change.status[1] === 0) { + await git.remove({ ...this._baseOpts, filepath: convertToPosixSep(path.join('.', change.path)) }); + } else { + await git.add({ ...this._baseOpts, filepath: convertToPosixSep(path.join('.', change.path)) }); + } + } + } + + async unstageChanges(changes: { path: string; status: [git.HeadStatus, git.WorkdirStatus, git.StageStatus] }[]) { + for (const change of changes) { + await git.remove({ ...this._baseOpts, filepath: change.path }); + + // If the file was deleted in stage, we need to restore it + if (change.status[2] === 0) { + await git.checkout({ + ...this._baseOpts, + ref: await this.getCurrentBranch(), + force: true, + filepaths: [change.path], + }); + } + } + } + + async discardChanges(changes: { path: string; status: [git.HeadStatus, git.WorkdirStatus, git.StageStatus] }[]) { + for (const change of changes) { + // If the file didn't exist in HEAD, we need to remove it + if (change.status[0] === 0) { + await git.remove({ ...this._baseOpts, filepath: change.path }); + // @ts-expect-error -- TSCONVERSION + await this._baseOpts.fs.promises.unlink(change.path); + } else { + await git.checkout({ + ...this._baseOpts, + force: true, + ref: await this.getCurrentBranch(), + filepaths: [convertToPosixSep(change.path)], + }); + } + + } } static sortBranches(branches: string[]) { diff --git a/packages/insomnia/src/sync/git/ne-db-client.ts b/packages/insomnia/src/sync/git/ne-db-client.ts index e42431882f81..7c2b7e0c4f4e 100644 --- a/packages/insomnia/src/sync/git/ne-db-client.ts +++ b/packages/insomnia/src/sync/git/ne-db-client.ts @@ -156,8 +156,29 @@ export class NeDBClient { ]; } else if (type !== null && id === null) { const workspace = await db.get(models.workspace.type, this._workspaceId); + let typeFilter = [type]; + const modelTypesWithinFolders = [models.request.type, models.grpcRequest.type, models.webSocketRequest.type]; - const typeFilter = modelTypesWithinFolders.includes(type) ? [models.requestGroup.type, type] : [type]; + if (modelTypesWithinFolders.includes(type)) { + typeFilter = [models.requestGroup.type, type]; + }; + + if (type === models.unitTest.type) { + typeFilter = [models.unitTestSuite.type, type]; + } + + if (type === models.protoFile.type) { + typeFilter = [models.protoDirectory.type, type]; + } + + if (type === models.mockRoute.type) { + typeFilter = [models.mockServer.type, type]; + } + + if (type === models.webSocketPayload.type) { + typeFilter = [models.webSocketRequest.type, type]; + } + const children = await db.withDescendants(workspace, null, typeFilter); docs = children.filter(d => d.type === type && !d.isPrivate); } else { diff --git a/packages/insomnia/src/sync/git/routable-fs-client.ts b/packages/insomnia/src/sync/git/routable-fs-client.ts index df1561375973..07e4e00374ec 100644 --- a/packages/insomnia/src/sync/git/routable-fs-client.ts +++ b/packages/insomnia/src/sync/git/routable-fs-client.ts @@ -31,6 +31,13 @@ export function routableFSClient( // TODO: remove non-null assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const result = await defaultFS.promises[method]!(filePath, ...args); + // If the method is returning a list of files for the root directory + // we need to return the actual result plus inject the .insomnia directory + // so that git will try to find changes inside that directory + if (method === 'readdir' && filePath === '.') { + // console.log('[routablefs] Executing', method, filePath, { args }); + return ['.insomnia', ...result]; + } // Uncomment this to debug operations // console.log('[routablefs] Executing', method, filePath, { args }, { result }); return result; 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 2c8e82770fc7..e1871ed5463d 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -1,5 +1,5 @@ import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; -import React, { type FC, useEffect, useState } from 'react'; +import React, { type FC, useEffect, useRef, useState } from 'react'; import { Button, Collection, Menu, MenuItem, MenuTrigger, Popover, Section, Tooltip, TooltipTrigger } from 'react-aria-components'; import { useFetcher, useParams, useRevalidator } from 'react-router-dom'; import { useInterval } from 'react-use'; @@ -48,6 +48,8 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable const gitFetchFetcher = useFetcher(); const gitStatusFetcher = useFetcher(); + const isCheckingGitChanges = useRef(false); + const loadingPush = gitPushFetcher.state === 'loading'; const loadingPull = gitPullFetcher.state === 'loading'; const loadingFetch = gitFetchFetcher.state === 'loading'; @@ -60,7 +62,6 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable gitRepoDataFetcher.state === 'idle' && !gitRepoDataFetcher.data ) { - console.log('[git:fetcher] Fetching git repo data'); gitRepoDataFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/repo`); } }, [ @@ -77,7 +78,6 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable useEffect(() => { if (shouldFetchGitRepoStatus) { - console.log('[git:fetcher] Fetching git repo status'); gitStatusFetcher.submit({}, { action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/status`, method: 'post', @@ -85,10 +85,13 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable } }, [gitStatusFetcher, organizationId, projectId, shouldFetchGitRepoStatus, workspaceId]); - useInterval(() => { - requestIdleCallback(() => { - checkGitChanges(workspaceId); - }); + useInterval(async () => { + if (isCheckingGitChanges.current) { + return; + } + isCheckingGitChanges.current = true; + await checkGitChanges(workspaceId); + isCheckingGitChanges.current = false; }, 30 * 1000); useEffect(() => { @@ -361,7 +364,6 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable disabledKeys={allSyncMenuActionList.filter(item => item?.isDisabled).map(item => item.id)} onAction={key => { const item = allSyncMenuActionList.find(item => item.id === key); - console.log('onAction', key, item); item?.action(); }} className="border max-w-lg select-none text-sm border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none" @@ -444,7 +446,7 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable )} {isGitStagingModalOpen && gitRepository && ( setIsGitStagingModalOpen(false)} + onClose={() => setIsGitStagingModalOpen(false)} /> )} diff --git a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx index ff9f311c4dc6..7bf22a8c1004 100644 --- a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx @@ -135,7 +135,7 @@ export const ProjectDropdown: FC = ({ project, organizationId, storage }) diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index 0890b5e3b5f5..a60c7cc74ffc 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -110,9 +110,9 @@ export const WorkspaceCardDropdown: FC = props => { } > - + setIsDuplicateModalOpen(true)} /> 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 b92f8e993c39..8ca9bdc92e55 100644 --- a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx @@ -1,58 +1,148 @@ -import classnames from 'classnames'; -import React, { type FC, useEffect, useRef } from 'react'; -import { OverlayContainer } from 'react-aria'; +import { Differ, Viewer } from 'json-diff-kit'; +import React, { type FC, useEffect } from 'react'; +import { Button, Dialog, GridList, GridListItem, Heading, Label, Modal, ModalOverlay, TextArea, TextField } from 'react-aria-components'; import { useFetcher, useParams } from 'react-router-dom'; -import { tinykeys } from 'tinykeys'; -import { strings } from '../../../common/strings'; -import * as models from '../../../models'; -import type { CommitToGitRepoResult, GitChangesLoaderData, GitRollbackChangesResult } from '../../routes/git-actions'; -import { IndeterminateCheckbox } from '../base/indeterminate-checkbox'; -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 { PromptButton } from '../base/prompt-button'; -import { Tooltip } from '../tooltip'; +import type { CommitToGitRepoResult, GitChangesLoaderData, GitDiffResult } from '../../routes/git-actions'; +import { Icon } from '../icon'; import { showAlert } from '.'; -interface Item { - path: string; - type: string; - status: string; - staged: boolean; - added: boolean; - editable: boolean; +const differ = new Differ({ + detectCircular: true, + maxDepth: Infinity, + showModifications: true, + arrayDiffMethod: 'lcs', +}); + +function getDiff(previewDiffItem: { + before: string; + after: string; +}) { + let prev = null; + let next = null; + + try { + prev = JSON.parse(previewDiffItem.before); + } catch (e) { + // Nothing to do + } + + try { + next = JSON.parse(previewDiffItem.after); + } catch (e) { + // Nothing to do + } + + return differ.diff(prev, next); } -export const GitStagingModal: FC = ({ - onHide, +function getPreviewItemName(previewDiffItem: { + before: string; + after: string; +}) { + let prevName = ''; + let nextName = ''; + + try { + const prev = JSON.parse(previewDiffItem.before); + + if (prev && 'fileName' in prev || 'name' in prev) { + prevName = prev.fileName || prev.name; + } + } catch (e) { + // Nothing to do + } + + try { + const next = JSON.parse(previewDiffItem.after); + if (next && 'fileName' in next || 'name' in next) { + nextName = next.fileName || next.name; + } + } catch (e) { + // Nothing to do + } + + return nextName || prevName; +} + +export const GitStagingModal: FC<{ onClose: () => void }> = ({ + onClose, }) => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; workspaceId: string; }; - const modalRef = useRef(null); - const formRef = useRef(null); - const [checkAllModified, setCheckAllModified] = React.useState(false); - const [checkAllUnversioned, setCheckAllUnversioned] = React.useState(false); const gitChangesFetcher = useFetcher(); const gitCommitFetcher = useFetcher(); - const rollbackFetcher = useFetcher(); + const rollbackFetcher = useFetcher<{ + errors?: string[]; + }>(); + const stageChangesFetcher = useFetcher<{ + errors?: string[]; + }>(); + const unstageChangesFetcher = useFetcher<{ + errors?: string[]; + }>(); + const undoUnstagedChangesFetcher = useFetcher<{ + errors?: string[]; + }>(); + const diffChangesFetcher = useFetcher(); - useEffect(() => { - const unsubscribe = tinykeys(document.body, { - 'esc': () => modalRef.current?.hide(), - }); - return unsubscribe; - }, []); + function diffChanges({ path, staged }: { path: string; staged: boolean }) { + let url = `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/diff`; + const params = new URLSearchParams(); + params.set('filepath', path); + params.set('staged', staged ? 'true' : 'false'); + url += '?' + params.toString(); + diffChangesFetcher.load(`${url}`); + } - const isLoadingGitChanges = gitChangesFetcher.state !== 'idle'; + function stageChanges(paths: string[]) { + stageChangesFetcher.submit( + { + paths, + }, + { + method: 'POST', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/stage`, + encType: 'application/json', + } + ); + } - useEffect(() => { - modalRef.current?.show(); - }, []); + function unstageChanges(paths: string[]) { + unstageChangesFetcher.submit( + { + paths, + }, + { + method: 'POST', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/unstage`, + encType: 'application/json', + } + ); + } + + function undoUnstagedChanges(paths: string[]) { + showAlert({ + message: 'Are you sure you want to undo your changes? This action cannot be undone and will revert all changes made since the last commit that are unstaged.', + title: 'Undo changes', + onConfirm: () => { + undoUnstagedChangesFetcher.submit( + { + paths, + }, + { + method: 'POST', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/discard`, + encType: 'application/json', + } + ); + }, + addCancel: true, + }); + } useEffect(() => { if (gitChangesFetcher.state === 'idle' && !gitChangesFetcher.data) { @@ -62,21 +152,22 @@ export const GitStagingModal: FC = ({ const { changes, - branch, - statusNames, } = gitChangesFetcher.data || { - changes: [], + changes: { + staged: [], + unstaged: [], + }, branch: '', statusNames: {}, }; - const hasChanges = Boolean(changes.length); - - const modifiedChanges = changes.filter(i => !i.status.includes('added')); - const unversionedChanges = changes.filter(i => i.status.includes('added')); - + const { Form, formAction, state, data } = useFetcher<{ errors?: string[] }>(); const errors = gitCommitFetcher.data?.errors || rollbackFetcher.data?.errors; + const isCreatingSnapshot = state === 'loading' && formAction === '/organization/:organizationId/project/:projectId/workspace/:workspaceId/git/commit'; + const isPushing = state === 'loading' && formAction === '/organization/:organizationId/project/:projectId/workspace/:workspaceId/git/commit-and-push'; + const previewDiffItem = diffChangesFetcher.data && 'diff' in diffChangesFetcher.data ? diffChangesFetcher.data.diff : null; + useEffect(() => { if (errors && errors?.length > 0) { showAlert({ @@ -86,304 +177,277 @@ export const GitStagingModal: FC = ({ } }, [errors]); + const allChanges = [...changes.staged, ...changes.unstaged]; + const allChangesLength = allChanges.length; + const noCommitErrors = data && 'errors' in data && data.errors?.length === 0; + + useEffect(() => { + if (allChangesLength === 0 && noCommitErrors) { + onClose(); + } + }, [allChangesLength, onClose, noCommitErrors]); + return ( - - - Commit Changes - - - {hasChanges ? ( - <> -
-