From d693ea90a01f9e54c82732bc83ae480af0faa02e Mon Sep 17 00:00:00 2001 From: Pavlos Koutoglou Date: Thu, 20 Aug 2026 15:16:27 +0200 Subject: [PATCH] fix(git): fix folder naming, relocation, and resurrection bugs for git-synced projects (#10428) --- .../tests/smoke/custom-lint-rules.test.ts | 40 +++- .../tests/smoke/git-repo-relocation.test.ts | 62 +++-- packages/insomnia/src/entry.preload.ts | 1 + .../git-service-clone-folder-naming.test.ts | 124 ++++++++++ .../__tests__/git-service-repo-folder.test.ts | 209 +++++++++++++++++ packages/insomnia/src/main/git-service.ts | 220 ++++++++++++++++-- packages/insomnia/src/main/ipc/electron.ts | 1 + .../git/__tests__/repo-file-watcher.test.ts | 74 ++++++ .../src/sync/git/repo-file-watcher.ts | 29 ++- .../dropdowns/git-project-sync-dropdown.tsx | 22 +- .../modals/git-project-staging-modal.tsx | 17 +- .../ui/components/project/git-repo-form.tsx | 100 ++++---- .../project/project-create-form.tsx | 10 +- .../project/project-settings-form.tsx | 72 ++++-- .../src/ui/components/project/utils.test.ts | 80 +++++++ .../src/ui/components/project/utils.tsx | 29 ++- 16 files changed, 989 insertions(+), 101 deletions(-) create mode 100644 packages/insomnia/src/main/__tests__/git-service-clone-folder-naming.test.ts create mode 100644 packages/insomnia/src/main/__tests__/git-service-repo-folder.test.ts create mode 100644 packages/insomnia/src/ui/components/project/utils.test.ts diff --git a/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts b/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts index 93fa0e0f8a..809dc7dc0f 100644 --- a/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts @@ -131,23 +131,51 @@ async function createGitDesignDocument(insomnia: InsomniaApp, page: Page, projec await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0'); } +// Mirrors models.gitRepository.getGitRepoFolderName()'s safety check on +// `folderSlug` before baking it into a filesystem path. +const SAFE_FOLDER_SLUG_PATTERN = /^[a-z0-9-]+$/; + /** * Find the RepoFileWatcher mirror directory for the first GitRepository in * `dataPath`. Polls for up to 6 seconds because NeDB flushes to disk * asynchronously after the project is created. + * + * Mirrors the app's own path resolution (see `getRepoBaseDir` / + * `models.gitRepository.getGitRepoFolderName`): a user-chosen `directory` + * wins when set; otherwise the managed folder is named `git__` + * once a `folderSlug` has been recorded (set at clone time, or by the + * one-time startup backfill for older repos), falling back to the bare id + * only when neither applies. */ async function gitRepoMirrorPath(dataPath: string): Promise { const dbPath = path.join(dataPath, 'insomnia.GitRepository.db'); for (let attempt = 0; attempt < 30; attempt++) { try { const content = await fs.promises.readFile(dbPath, 'utf8'); - const repos = content - .split('\n') - .filter(Boolean) - .map((l: string) => JSON.parse(l)) - .filter((r: any) => !r.$$deleted); + // NeDB's on-disk format is an append-only log: an update appends a new + // line for the same `_id` rather than rewriting it in place (e.g. the + // folderSlug update that follows creation). Replay all lines in order, + // keyed by `_id`, so the last write for a given repo wins. + const byId = new Map(); + for (const line of content.split('\n')) { + if (!line) { + continue; + } + const doc = JSON.parse(line); + byId.set(doc._id, doc); + } + const repos = [...byId.values()].filter((r: any) => !r.$$deleted); if (repos.length > 0) { - return path.join(dataPath, 'version-control', 'git', repos[0]._id); + const repo = repos[0]; + if (repo.directory) { + return repo.directory; + } + const slug = repo.folderSlug; + const folderName = + typeof slug === 'string' && SAFE_FOLDER_SLUG_PATTERN.test(slug) + ? `git_${slug}_${(repo._id as string).replace(/^git_/, '')}` + : repo._id; + return path.join(dataPath, 'version-control', 'git', folderName); } } catch { // file not yet written diff --git a/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts index 359aec51ff..cb32740826 100644 --- a/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts @@ -8,8 +8,6 @@ import type { InsomniaApp } from '../../playwright/pages'; import { test } from '../../playwright/test'; import { mockOpenDialogForDirectory } from '../../playwright/utils'; -// deriveRepoName('http://localhost:4010/git/git-server.git') === 'git-server' -const DERIVED_REPO_NAME = 'git-server'; const GIT_PROJECT_NAME = 'Relocation Test Project'; const makeTempDir = (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -27,44 +25,72 @@ test.describe('Git repository relocation', () => { await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup'); }); - test('moves the repo to a new parent folder and updates the displayed path', async ({ insomnia, page }) => { + test('moves the repo into the picked folder and updates the displayed path', async ({ insomnia, page }) => { // Ensure the project dashboard URL has settled before interacting with the sidebar. await insomnia.projectPage.waitForProjectDashboard(); - const destParent = makeTempDir('insomnia-relocate-dest-'); - const expectedPath = path.join(destParent, DERIVED_REPO_NAME); + // The picked folder IS the new location itself now — no more auto-appended + // repo-named subfolder (see relocateGitRepoAction's doc comment). An + // already-existing-but-empty folder (like this freshly made temp dir) is + // still a valid target: it gets cleared and the repo moved in. + const destDir = makeTempDir('insomnia-relocate-dest-'); try { await openProjectSettingsModal(insomnia, GIT_PROJECT_NAME); - await mockOpenDialogForDirectory(insomnia.app, destParent); + await mockOpenDialogForDirectory(insomnia.app, destDir); await page.getByRole('button', { name: 'Move repository to another folder' }).click(); // Path display updates immediately from the action result (before the loader revalidates). - await expect.soft(page.getByTitle(expectedPath)).toBeVisible({ timeout: 30_000 }); + await expect.soft(page.getByTitle(destDir)).toBeVisible({ timeout: 30_000 }); - // The new directory must exist on disk (rename if source existed, mkdir otherwise). - await expect.poll(() => fs.existsSync(expectedPath), { timeout: 30_000 }).toBe(true); + // The directory must still exist on disk (rename if source existed, mkdir otherwise). + await expect.poll(() => fs.existsSync(destDir), { timeout: 30_000 }).toBe(true); } finally { - fs.rmSync(destParent, { recursive: true, force: true }); + fs.rmSync(destDir, { recursive: true, force: true }); } }); - test('shows an error when the destination folder already exists (collision guard)', async ({ insomnia, page }) => { + test('adopts the picked folder in place when it already contains a git repo', async ({ insomnia, page }) => { await insomnia.projectPage.waitForProjectDashboard(); - const destParent = makeTempDir('insomnia-relocate-collision-'); - // Pre-create the derived subdirectory so the collision guard fires. - fs.mkdirSync(path.join(destParent, DERIVED_REPO_NAME)); + // Simulate reconnecting to a folder the repo was externally renamed/moved + // to: it already has its own `.git` and content, so relocating onto it + // must repoint `directory` only — no move/copy, nothing overwritten. + const destDir = makeTempDir('insomnia-relocate-adopt-'); + fs.mkdirSync(path.join(destDir, '.git')); + fs.writeFileSync(path.join(destDir, 'insomnia.wrk_marker.yaml'), 'marker: pre-existing\n'); try { await openProjectSettingsModal(insomnia, GIT_PROJECT_NAME); - await mockOpenDialogForDirectory(insomnia.app, destParent); + await mockOpenDialogForDirectory(insomnia.app, destDir); await page.getByRole('button', { name: 'Move repository to another folder' }).click(); - // Error banner text matches relocateGitRepoAction's collision message. - await expect.soft(page.getByText(/That folder already exists/i)).toBeVisible({ timeout: 15_000 }); + await expect.soft(page.getByTitle(destDir)).toBeVisible({ timeout: 30_000 }); + await expect.soft(page.getByText(/Repository moved to/i)).toBeVisible({ timeout: 15_000 }); + + // The pre-existing marker file must be untouched — this was an adopt, not a move. + expect.soft(fs.readFileSync(path.join(destDir, 'insomnia.wrk_marker.yaml'), 'utf8')).toBe('marker: pre-existing\n'); } finally { - fs.rmSync(destParent, { recursive: true, force: true }); + fs.rmSync(destDir, { recursive: true, force: true }); + } + }); + + test('shows an error when the picked folder has unrelated files in it', async ({ insomnia, page }) => { + await insomnia.projectPage.waitForProjectDashboard(); + + const destDir = makeTempDir('insomnia-relocate-collision-'); + // Unrelated content, no `.git` — neither a valid move target nor adoptable. + fs.writeFileSync(path.join(destDir, 'unrelated.txt'), 'not a repo'); + try { + await openProjectSettingsModal(insomnia, GIT_PROJECT_NAME); + + await mockOpenDialogForDirectory(insomnia.app, destDir); + await page.getByRole('button', { name: 'Move repository to another folder' }).click(); + + // Error banner text matches relocateGitRepoAction's non-empty/non-git message. + await expect.soft(page.getByText(/isn't a git repository/i)).toBeVisible({ timeout: 15_000 }); + } finally { + fs.rmSync(destDir, { recursive: true, force: true }); } }); }); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index b089e9b1e1..b520ccba51 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -215,6 +215,7 @@ const git: GitServiceAPI = { cloneGitRepo: options => invokeWithNormalizedError('git.cloneGitRepo', options), openGitRepo: options => invokeWithNormalizedError('git.openGitRepo', options), checkGitRepoDirectory: options => invokeWithNormalizedError('git.checkGitRepoDirectory', options), + resolveGitRepoFolderPath: options => invokeWithNormalizedError('git.resolveGitRepoFolderPath', options), cleanupGitRepoStorage: options => invokeWithNormalizedError('git.cleanupGitRepoStorage', options), relocateGitRepo: options => invokeWithNormalizedError('git.relocateGitRepo', options), initGitRepoClone: options => invokeWithNormalizedError('git.initGitRepoClone', options), diff --git a/packages/insomnia/src/main/__tests__/git-service-clone-folder-naming.test.ts b/packages/insomnia/src/main/__tests__/git-service-clone-folder-naming.test.ts new file mode 100644 index 0000000000..6d77c92c42 --- /dev/null +++ b/packages/insomnia/src/main/__tests__/git-service-clone-folder-naming.test.ts @@ -0,0 +1,124 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { models, services } from 'insomnia-data'; +import type * as IsomorphicGit from 'isomorphic-git'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { database as db } from '~/common/database'; + +// git-service.ts pulls in main/analytics.ts, which pulls in @sentry/electron +// and other Electron-main-process-only globals that aren't relevant to the +// folder-naming logic under test here (and don't play well with the +// project's lightweight `electron` test mock). Replace it with a no-op that +// keeps the real, dependency-free `AnalyticsEvent` enum intact. +vi.mock('~/main/analytics', async () => { + const { AnalyticsEvent } = await import('insomnia-analytics'); + return { + AnalyticsEvent, + trackAnalyticsEvent: vi.fn(), + setCurrentOrganizationId: vi.fn(), + trackPageView: vi.fn(), + }; +}); + +// The global test mock stubs isomorphic-git's `clone` as a total no-op (fine +// for tests that never look inside the result), which leaves no `.git` +// metadata at all — later calls in the clone flow (setConfig, currentBranch) +// then throw trying to read nonexistent git internals. Redirect `clone` to a +// real (network-free) `git.init` against whatever fs/dir it's given instead, +// producing a minimal-but-valid empty repo good enough for the rest of the +// clone flow to run for real. +vi.mock('isomorphic-git', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + clone: vi.fn(async ({ fs, dir, gitdir }: { fs: unknown; dir: string; gitdir: string }) => { + await actual.init({ fs: fs as never, dir, gitdir, defaultBranch: 'main' }); + }), + push: vi.fn(), + }; +}); + +const { cloneGitRepoAction } = await import('~/main/git-service'); + +const ORGANIZATION_ID = 'org_test'; + +describe('cloneGitRepoAction folder naming', () => { + let tmpParent: string; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + tmpParent = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-clone-')); + }); + + const getRepoForProject = async (projectId: string) => { + const project = await services.project.getById(projectId); + const repoId = models.project.decodeRepoId(project!.gitRepositoryId!); + return services.gitRepository.getById(repoId); + }; + + // Regression: folderSlug used to only ever get set by the one-time startup + // backfill, never at clone time — so every repo cloned into the default + // (app-managed) location kept the unreadable `git_` folder name until + // the next app restart. + it('sets folderSlug from the project name when cloning into the managed (default) location', async () => { + const result = await cloneGitRepoAction({ + organizationId: ORGANIZATION_ID, + credentialsId: null, + uri: 'https://example.com/my-repo.git', + name: 'My Cool Project', + }); + + expect(result.errors).toBeUndefined(); + if (!result.projectId) { + throw new Error('expected a successful clone result with a projectId'); + } + const repo = await getRepoForProject(result.projectId); + expect(repo?.directory).toBeNull(); + expect(repo?.folderSlug).toBe('my-cool-project'); + }); + + it('leaves folderSlug null when cloning into a user-chosen directory (irrelevant there)', async () => { + const target = path.join(tmpParent, 'my-repo'); + + const result = await cloneGitRepoAction({ + organizationId: ORGANIZATION_ID, + credentialsId: null, + uri: 'https://example.com/my-repo.git', + directory: target, + name: 'My Project', + }); + + expect(result.errors).toBeUndefined(); + if (!result.projectId) { + throw new Error('expected a successful clone result with a projectId'); + } + const repo = await getRepoForProject(result.projectId); + expect(repo?.directory).toBe(target); + expect(repo?.folderSlug).toBeNull(); + }); + + // Same fix, but the other code path: cloning a new workspace into an + // EXISTING project (`projectId` provided) rather than creating a new one. + it('sets folderSlug from the existing project\'s name when cloning a workspace into it', async () => { + const project = await services.project.create({ name: 'Existing Project', parentId: ORGANIZATION_ID }); + + const result = await cloneGitRepoAction({ + organizationId: ORGANIZATION_ID, + projectId: project._id, + credentialsId: null, + uri: 'https://example.com/other-repo.git', + }); + + expect(result.errors).toBeUndefined(); + if (!result.workspaceId) { + throw new Error('expected a successful clone result with a workspaceId'); + } + const meta = await services.workspaceMeta.getByParentId(result.workspaceId); + const repo = await services.gitRepository.getById(meta!.gitRepositoryId!); + expect(repo?.directory).toBeNull(); + expect(repo?.folderSlug).toBe('existing-project'); + }); +}); diff --git a/packages/insomnia/src/main/__tests__/git-service-repo-folder.test.ts b/packages/insomnia/src/main/__tests__/git-service-repo-folder.test.ts new file mode 100644 index 0000000000..e2952b8dbe --- /dev/null +++ b/packages/insomnia/src/main/__tests__/git-service-repo-folder.test.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { services } from 'insomnia-data'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { database as db } from '~/common/database'; + +// git-service.ts pulls in main/analytics.ts, which pulls in @sentry/electron +// and other Electron-main-process-only globals that aren't relevant to the +// folder-relocation logic under test here (and don't play well with the +// project's lightweight `electron` test mock). Replace it with a no-op that +// keeps the real, dependency-free `AnalyticsEvent` enum intact. +vi.mock('~/main/analytics', async () => { + const { AnalyticsEvent } = await import('insomnia-analytics'); + return { + AnalyticsEvent, + trackAnalyticsEvent: vi.fn(), + setCurrentOrganizationId: vi.fn(), + trackPageView: vi.fn(), + }; +}); + +const { relocateGitRepoAction, resolveGitRepoFolderPathAction } = await import('~/main/git-service'); + +const PROJECT_ID = 'proj_test'; + +describe('resolveGitRepoFolderPathAction', () => { + let repoDir: string; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-repo-folder-')); + }); + + afterEach(async () => { + await fs.promises.rm(repoDir, { recursive: true, force: true }); + }); + + it('returns the path without touching disk when the folder exists', async () => { + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: repoDir }); + + const result = await resolveGitRepoFolderPathAction({ gitRepositoryId: repo._id }); + + expect(result).toEqual({ path: repoDir }); + }); + + // Regression: clicking "Open in file system" after the folder was renamed/ + // moved/deleted outside Insomnia used to silently `mkdir -p` it back into + // existence and open the resurrected empty folder. It must instead report + // the folder missing and create nothing. + it('reports an error and creates nothing when the folder was moved/renamed/deleted externally', async () => { + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: repoDir }); + + await fs.promises.rm(repoDir, { recursive: true, force: true }); + + const result = await resolveGitRepoFolderPathAction({ gitRepositoryId: repo._id }); + + expect(result.path).toBeUndefined(); + expect(result.errors?.[0]).toContain('Repository folder not found'); + const recreated = await fs.promises + .access(repoDir) + .then(() => true) + .catch(() => false); + expect(recreated).toBe(false); + }); + + it('errors for an unknown repository id', async () => { + const result = await resolveGitRepoFolderPathAction({ gitRepositoryId: 'git_does_not_exist' }); + expect(result.errors?.[0]).toContain('Git repository not found'); + }); +}); + +describe('relocateGitRepoAction', () => { + let parentDir: string; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-relocate-')); + }); + + afterEach(async () => { + await fs.promises.rm(parentDir, { recursive: true, force: true }); + }); + + const readFile = (p: string) => fs.promises.readFile(p, 'utf8').catch(() => null); + + it('moves the repository into an empty target folder', async () => { + const currentDir = path.join(parentDir, 'current'); + await fs.promises.mkdir(currentDir, { recursive: true }); + await fs.promises.writeFile(path.join(currentDir, 'insomnia.wrk_a.yaml'), 'name: A\n', 'utf8'); + + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const targetDir = path.join(parentDir, 'moved'); + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: targetDir }); + + expect(result).toEqual({ directory: targetDir }); + expect(await readFile(path.join(targetDir, 'insomnia.wrk_a.yaml'))).toBe('name: A\n'); + const oldStillThere = await fs.promises + .access(currentDir) + .then(() => true) + .catch(() => false); + expect(oldStillThere).toBe(false); + + const updated = await services.gitRepository.getById(repo._id); + expect(updated?.directory).toBe(targetDir); + }); + + // Regression ("reconnect after external rename"): the folder was renamed/ + // moved outside Insomnia and already contains its own `.git` — relocating + // onto it must ADOPT it in place (repoint `directory` only) rather than + // refusing, or trying to move/overwrite it. + it('adopts a target folder that already contains a git repo, without moving or copying anything', async () => { + const currentDir = path.join(parentDir, 'stale-path'); // no longer exists on disk + const renamedDir = path.join(parentDir, 'already-renamed'); + await fs.promises.mkdir(path.join(renamedDir, '.git'), { recursive: true }); + await fs.promises.writeFile(path.join(renamedDir, 'insomnia.wrk_a.yaml'), 'name: Renamed\n', 'utf8'); + + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: renamedDir }); + + expect(result).toEqual({ directory: renamedDir }); + // The pre-existing content must be untouched — this was an adopt, not a move. + expect(await readFile(path.join(renamedDir, 'insomnia.wrk_a.yaml'))).toBe('name: Renamed\n'); + + const updated = await services.gitRepository.getById(repo._id); + expect(updated?.directory).toBe(renamedDir); + }); + + it('refuses to relocate onto a non-empty folder that is not a git repository', async () => { + const currentDir = path.join(parentDir, 'current'); + await fs.promises.mkdir(currentDir, { recursive: true }); + + const targetDir = path.join(parentDir, 'has-other-stuff'); + await fs.promises.mkdir(targetDir, { recursive: true }); + await fs.promises.writeFile(path.join(targetDir, 'unrelated.txt'), 'not a repo', 'utf8'); + + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: targetDir }); + + expect(result.errors?.[0]).toContain("isn't a git repository"); + // Must not have touched the unrelated file. + expect(await readFile(path.join(targetDir, 'unrelated.txt'))).toBe('not a repo'); + }); + + it('moves into a target folder that only has a macOS .DS_Store file', async () => { + const currentDir = path.join(parentDir, 'current'); + await fs.promises.mkdir(currentDir, { recursive: true }); + await fs.promises.writeFile(path.join(currentDir, 'insomnia.wrk_a.yaml'), 'name: A\n', 'utf8'); + + const targetDir = path.join(parentDir, 'ds-store-only'); + await fs.promises.mkdir(targetDir, { recursive: true }); + await fs.promises.writeFile(path.join(targetDir, '.DS_Store'), 'junk', 'utf8'); + + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: targetDir }); + + expect(result).toEqual({ directory: targetDir }); + expect(await readFile(path.join(targetDir, 'insomnia.wrk_a.yaml'))).toBe('name: A\n'); + }); + + it('rejects moving onto the folder already connected to another project', async () => { + const currentDir = path.join(parentDir, 'current'); + await fs.promises.mkdir(currentDir, { recursive: true }); + const otherDir = path.join(parentDir, 'other-project-dir'); + await fs.promises.mkdir(otherDir, { recursive: true }); + await services.gitRepository.create({ uri: 'https://example.com/other.git', directory: otherDir }); + + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: otherDir }); + + expect(result.errors?.[0]).toContain('A project is already connected to this folder'); + }); + + it('treats picking the same, still-available folder as a no-op error', async () => { + const currentDir = path.join(parentDir, 'current'); + await fs.promises.mkdir(currentDir, { recursive: true }); + + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: currentDir }); + + expect(result.errors?.[0]).toBe('The repository is already in that folder.'); + }); + + // Regression: previously, picking the same PARENT the recovery flow computed + // from a stale, no-longer-existing `directory` always hit the "already in + // that folder" short-circuit and refused — even though nothing was actually + // there. It must fall through to the normal (adopt/move) handling instead. + it('falls through instead of refusing when the same path is picked but is no longer on disk', async () => { + const currentDir = path.join(parentDir, 'gone'); // never created — simulates an external rename/delete + const repo = await services.gitRepository.create({ uri: 'https://example.com/foo.git', directory: currentDir }); + + const result = await relocateGitRepoAction({ gitRepositoryId: repo._id, projectId: PROJECT_ID, newDirectory: currentDir }); + + expect(result).toEqual({ directory: currentDir }); + const recreated = await fs.promises + .access(currentDir) + .then(() => true) + .catch(() => false); + expect(recreated).toBe(true); + }); +}); diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 2f48bbe068..7db3794a58 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -577,15 +577,53 @@ async function getGitFSClient({ gitRepositoryId, directory, folderSlug, + requireExisting, }: { projectId: string; workspaceId?: string; gitRepositoryId: string; directory?: string | null; folderSlug?: string | null; + /** + * When true, refuses to hand back an fs client for a user-owned folder + * (`directory` set) that isn't currently on disk, throwing instead. + * + * Every `fsClient()` below `mkdir -p`s its base path on construction — fine + * for a brand-new clone/adopt into a not-yet-existing folder, but for any + * caller that's just *reading* an already-established repo (branch lookups, + * the file-tree view, etc.) that eager mkdir would silently resurrect a + * folder the user moved, renamed, or deleted outside Insomnia (or whose + * drive is unmounted) — with no clone/relocate action from the user at all. + * Callers that legitimately create the folder (clone, adopt, relocate) + * must leave this unset. Irrelevant for app-managed folders, which are + * always safe to lazily create. + */ + requireExisting?: boolean; }) { + let dir = directory; + let slug = folderSlug; + if (dir === undefined) { + const repo = await services.gitRepository.getById(gitRepositoryId); + dir = repo?.directory ?? null; + slug = repo?.folderSlug ?? null; + } + // Base directory where Git data is stored - const baseDir = await getRepoBaseDir(gitRepositoryId, directory, folderSlug); + const baseDir = await getRepoBaseDir(gitRepositoryId, dir, slug); + + if (requireExisting && dir) { + let isAvailable = false; + try { + isAvailable = (await fs.promises.stat(baseDir)).isDirectory(); + } catch { + isAvailable = false; + } + if (!isAvailable) { + throw new Error( + `Repository folder not found at "${baseDir}". It may have been moved, renamed, or deleted outside Insomnia. Use "Move to another folder" to reconnect it to its new location.`, + ); + } + } // Workspace FS Client - used when working with a specific workspace if (workspaceId) { @@ -752,6 +790,7 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: workspaceId, directory: gitRepository.directory, folderSlug: gitRepository.folderSlug, + requireExisting: true, }); if (GitVCS.isInitializedForRepo(gitRepository._id) && !gitRepository.needsFullClone) { @@ -1428,7 +1467,7 @@ export const cloneGitRepoAction = async ({ } const bufferId = await database.bufferChanges(); - const gitRepository = await services.gitRepository.create(repoSettingsPatch); + let gitRepository = await services.gitRepository.create(repoSettingsPatch); async function getProject() { if (cloneIntoProjectId) { @@ -1454,6 +1493,17 @@ export const cloneGitRepoAction = async ({ const project = await getProject(); + // Give the app-managed folder a readable name derived from the project's + // name up front, instead of leaving it as the bare-id `git_` folder + // until the next app-startup backfill pass (see `backfillManagedFolderSlug`). + // Irrelevant when the user picked a `directory` — that folder name is theirs. + if (!gitRepository.directory) { + const slug = slugify(project.name); + if (slug) { + gitRepository = await services.gitRepository.update(gitRepository, { folderSlug: slug }); + } + } + const fsClient = await getGitFSClient({ projectId: project._id, gitRepositoryId: gitRepository._id, @@ -1536,6 +1586,16 @@ export const cloneGitRepoAction = async ({ const project = await services.project.getById(projectId); invariant(project, 'Project not found'); + // Give the app-managed folder a readable name up front (see the + // `folderSlug`-at-clone-time comment above) — irrelevant when the user + // picked a `directory`. + if (!repoSettingsPatch.directory) { + const slug = slugify(project.name); + if (slug) { + repoSettingsPatch.folderSlug = slug; + } + } + trackAnalyticsEvent(AnalyticsEvent.vcsSyncStart, { ...vcsEventProperties('git', 'clone'), provider, @@ -1891,12 +1951,21 @@ export const cleanupGitRepoStorageAction = async ({ gitRepositoryId }: { gitRepo }; /** - * Move a Git project's on-disk repository to a user-chosen folder and record the - * new location on `GitRepository.directory`. + * Point a Git project at a user-chosen folder and record the new location on + * `GitRepository.directory`. The picked folder IS the new location itself + * (not a parent to nest a repo-named subfolder under), so this doubles as two + * different operations depending on what's found there: * - * The whole repository (working tree + `.git`) is moved, so history and - * uncommitted changes are preserved. If the previous location was the managed - * folder it is left empty by the move (rename) or removed (cross-device copy). + * - Empty (or non-existent) folder: the whole repository (working tree + + * `.git`) is MOVED there, preserving history and uncommitted changes. If the + * previous location was the managed folder it is left empty by the move + * (rename) or removed (cross-device copy). + * - Folder that already contains a `.git`: ADOPTED in place instead — only + * `directory` is repointed, nothing is moved or copied. This is the + * "reconnect" path for when the repo's folder was renamed or moved outside + * Insomnia: the data already lives there, so there's nothing to move. + * - Folder that exists, is non-empty, and has no `.git`: refused, to never + * clobber unrelated user data. */ export const relocateGitRepoAction = async ({ gitRepositoryId, @@ -1917,7 +1986,16 @@ export const relocateGitRepoAction = async ({ const currentBaseDir = await getRepoBaseDir(repo._id, repo.directory, repo.folderSlug); if (path.resolve(currentBaseDir) === targetDir) { - return { errors: ['The repository is already in that folder.'] }; + const currentIsAvailable = await fs.promises + .stat(currentBaseDir) + .then(stat => stat.isDirectory()) + .catch(() => false); + if (currentIsAvailable) { + return { errors: ['The repository is already in that folder.'] }; + } + // Same path, but nothing is there right now (e.g. the folder was renamed + // away and back, or this happens to be the parent the recovery flow tried) + // — fall through instead of refusing; the checks below handle it correctly. } // Hard-block if another project already owns the target. @@ -1926,12 +2004,57 @@ export const relocateGitRepoAction = async ({ return { errors: [`A project is already connected to this folder: ${targetDir}`] }; } - // Refuse to move onto an existing path — never clobber user data. + // Adopt in place when the target already contains a git repo — see the + // "reconnect" case in the doc comment above. No files are moved or copied. + const targetHasGitRepo = await fs.promises + .access(path.join(targetDir, '.git')) + .then(() => true) + .catch(() => false); + + if (targetHasGitRepo) { + repoFileWatcherRegistry.stopWatcher(repo._id); + await services.gitRepository.update(repo, { directory: targetDir }); + + const adoptedFsClient = await getGitFSClient({ projectId, gitRepositoryId: repo._id, directory: targetDir }); + if (GitVCS.isInitializedForRepo(repo._id)) { + await GitVCS.init({ + repoId: repo._id, + uri: repo.uri, + directory: GIT_CLONE_DIR, + fs: adoptedFsClient, + gitDirectory: GIT_INTERNAL_DIR, + credentialsId: repo.credentialsId, + }); + } + await repoFileWatcherRegistry.startWatcher(repo._id, targetDir, projectId); + + return { directory: targetDir }; + } + + // Not a git repo — refuse to move onto it unless it's empty, to never + // clobber unrelated user data. macOS's auto-generated `.DS_Store` doesn't + // count against "empty". try { - await fs.promises.stat(targetDir); - return { errors: [`That folder already exists: ${targetDir}. Choose a folder that does not exist yet.`] }; + const stat = await fs.promises.stat(targetDir); + if (!stat.isDirectory()) { + return { errors: [`That path exists and is not a folder: ${targetDir}`] }; + } + const entries = await fs.promises.readdir(targetDir); + if (entries.some(entry => entry !== '.DS_Store')) { + return { + errors: [ + `That folder already has files in it and isn't a git repository: ${targetDir}. Choose an empty folder, or the folder containing the repository you want to reconnect.`, + ], + }; + } + // Exists and is (effectively) empty — clear it so the move below can + // create it fresh; fs.rename's cross-platform behaviour when the target + // already exists is inconsistent. + await fs.promises.rm(targetDir, { recursive: true, force: true }); } catch { - // Good — the destination does not exist. + // Either the destination does not exist at all (the common case), or some + // other stat/readdir error — either way, fall through and let the parent + // writable check and the move below surface anything that's actually wrong. } // The destination's parent must exist and be writable. @@ -3331,7 +3454,16 @@ const getRepositoryDirectoryTree = async ({ } const gitRepository = await getGitRepository({ projectId }); - const fs = await getGitFSClient({ projectId, gitRepositoryId: gitRepository._id }); + + const emptyTree = { repositoryTree: { id: '', name: 'Repository', type: 'root' as const, children: [] }, folderList: {} }; + + let fs: Awaited>; + try { + fs = await getGitFSClient({ projectId, gitRepositoryId: gitRepository._id, requireExisting: true }); + } catch (e) { + console.warn('[git] Could not read repository directory tree:', e); + return emptyTree; + } const rootContents = await fs.promises.readdir(GIT_CLONE_DIR); @@ -3520,10 +3652,15 @@ async function getCurrentBranchByRepositoryId({ repositoryId: string; projectId: string; }): Promise { - const fs = await getGitFSClient({ gitRepositoryId: repositoryId, projectId }); - return GitVCSClass.getRepoCurrentBranch({ - fs, - }); + try { + const fs = await getGitFSClient({ gitRepositoryId: repositoryId, projectId, requireExisting: true }); + return await GitVCSClass.getRepoCurrentBranch({ + fs, + }); + } catch (e) { + console.warn('[git] Could not read current branch:', e); + return ''; + } } /** @@ -3706,6 +3843,51 @@ export const checkGitRepoDirectoryAction = async ({ } }; +/** + * Resolves the on-disk path for a Git repository's "Open in file system" / + * "Open folder" actions, WITHOUT creating anything. Unlike the generic + * `openPath` IPC handler (which eagerly `mkdir -p`s to support opening + * arbitrary app-output locations that may not exist yet), a git repo's folder + * must already exist — silently recreating it as an empty directory when it + * was renamed, moved, or deleted outside Insomnia would hide that from the + * user (and risk the watcher treating the resurrected empty folder as a + * legitimately empty repo). Callers should surface `errors` instead of opening + * anything when the folder is missing. + */ +export const resolveGitRepoFolderPathAction = async ({ + gitRepositoryId, +}: { + gitRepositoryId: string; +}): Promise<{ path?: string; errors?: string[] }> => { + try { + const gitRepository = await services.gitRepository.getById(gitRepositoryId); + if (!gitRepository) { + return { errors: ['Git repository not found.'] }; + } + + const baseDir = await getRepoBaseDir(gitRepository._id, gitRepository.directory, gitRepository.folderSlug); + + let isAvailable = false; + try { + isAvailable = (await fs.promises.stat(baseDir)).isDirectory(); + } catch { + isAvailable = false; + } + + if (!isAvailable) { + return { + errors: [ + `Repository folder not found at "${baseDir}". It may have been moved, renamed, or deleted outside Insomnia. Use "Move to another folder" to reconnect it to its new location.`, + ], + }; + } + + return { path: baseDir }; + } catch (e) { + return { errors: [e instanceof Error ? e.message : 'Error resolving git repository folder.'] }; + } +}; + export interface GitServiceAPI { loadGitRepository: typeof loadGitRepository; getGitBranches: typeof getGitBranches; @@ -3717,6 +3899,7 @@ export interface GitServiceAPI { cloneGitRepo: typeof cloneGitRepoAction; openGitRepo: typeof openGitRepoAction; checkGitRepoDirectory: typeof checkGitRepoDirectoryAction; + resolveGitRepoFolderPath: typeof resolveGitRepoFolderPathAction; cleanupGitRepoStorage: typeof cleanupGitRepoStorageAction; relocateGitRepo: typeof relocateGitRepoAction; updateGitRepo: typeof updateGitRepoAction; @@ -3791,6 +3974,9 @@ export const registerGitServiceAPI = () => { ipcMainHandle('git.checkGitRepoDirectory', (_, options: Parameters[0]) => checkGitRepoDirectoryAction(options), ); + ipcMainHandle('git.resolveGitRepoFolderPath', (_, options: Parameters[0]) => + resolveGitRepoFolderPathAction(options), + ); ipcMainHandle('git.cleanupGitRepoStorage', (_, options: Parameters[0]) => cleanupGitRepoStorageAction(options), ); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 40614437ef..3e121feee7 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -81,6 +81,7 @@ export type HandleChannels = | 'git.multipleCommitToGitRepo' | 'git.openGitRepo' | 'git.checkGitRepoDirectory' + | 'git.resolveGitRepoFolderPath' | 'git.pullFromGitRemote' | 'git.relocateGitRepo' | 'git.pushToGitRemote' diff --git a/packages/insomnia/src/sync/git/__tests__/repo-file-watcher.test.ts b/packages/insomnia/src/sync/git/__tests__/repo-file-watcher.test.ts index 3e63bb9aec..f10726a934 100644 --- a/packages/insomnia/src/sync/git/__tests__/repo-file-watcher.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/repo-file-watcher.test.ts @@ -88,6 +88,80 @@ describe('RepoFileWatcher orphan reconciliation', () => { }); }); +describe('RepoFileWatcher deleted-folder resurrection', () => { + let repoDir: string; + let registry: RepoFileWatcherRegistry; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-repo-watcher-resurrect-')); + registry = makeRegistry(); + }); + + afterEach(async () => { + registry.stopAll(); + await fs.promises.rm(repoDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + const folderExists = (dir: string) => + fs.promises + .access(dir) + .then(() => true) + .catch(() => false); + + // Regression: a repo folder renamed/moved/deleted outside Insomnia must stay + // gone. Previously, flushWorkspacesToDisk() — which every git-status poll + // triggers via flushNow(), and every DB edit triggers via its debounced + // listener — unconditionally `mkdir -p`d the repo's base directory before + // writing, silently resurrecting it as an empty folder with no relocate/ + // reclone action from the user at all. + it('does not resurrect the repo folder via flushNow() after it is deleted externally', async () => { + const workspace = await createWorkspaceWithMeta('insomnia.wrk_local.yaml', Date.now()); + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + expect(await folderExists(repoDir)).toBe(true); + + // Simulate the user deleting/renaming the folder outside Insomnia. + await fs.promises.rm(repoDir, { recursive: true, force: true }); + expect(await folderExists(repoDir)).toBe(false); + + // Mirrors gitStatusAction's status-poll flush — the passive trigger that + // used to resurrect the folder with no user action at all. + await registry.flushNow(REPO_ID); + + expect(await folderExists(repoDir)).toBe(false); + // The workspace itself must survive too — it wasn't actually deleted, its + // folder was just temporarily unavailable. + expect(await services.workspace.getById(workspace._id)).not.toBeNull(); + }); + + // Regression: the content-hash dedup cache only reflects "has the DB + // changed since our last write", not "does the write still exist on disk". + // Once the folder comes back (e.g. via relocate/adopt), a flush must still + // restore its content even though the DB itself never changed — previously + // it stayed missing indefinitely because the unchanged hash short-circuited + // the write. + it('restores workspace content once the folder reappears, even though the DB never changed', async () => { + // `null` = never-synced-yet (local-only) — preserved and written to disk, + // rather than treated as "was synced, now missing on disk → orphaned". + await createWorkspaceWithMeta('insomnia.wrk_local.yaml', null); + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + const filePath = path.join(repoDir, 'insomnia.wrk_local.yaml'); + expect(await folderExists(filePath)).toBe(true); + + await fs.promises.rm(repoDir, { recursive: true, force: true }); + await registry.flushNow(REPO_ID); + expect(await folderExists(repoDir)).toBe(false); + + // The folder comes back (e.g. the app relocated/adopted it back into + // place) — empty, since nothing recreated its contents yet. + await fs.promises.mkdir(repoDir, { recursive: true }); + await registry.flushNow(REPO_ID); + + expect(await folderExists(filePath)).toBe(true); + }); +}); + describe('RepoFileWatcher ruleset import problems', () => { let repoDir: string; let registry: RepoFileWatcherRegistry; diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index 2002964809..778104b948 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -470,6 +470,21 @@ class RepoFileWatcher { * blocking import problem that the user must resolve first. */ private async flushWorkspacesToDisk(workspaceIds?: Set): Promise { + // Mirror importAllFiles' guard: this runs on every DB change (debounced) + // AND on every explicit flushNow() (e.g. git-status polling), regardless + // of whether the user did anything. Without this check, a repo folder + // that was deleted/renamed/moved outside Insomnia (or whose drive was + // unmounted) gets silently resurrected as an empty directory — via + // mkdir -p below — the next time either trigger fires, with no action + // from the user at all. + if (!(await this.repoDirIsAvailable())) { + console.warn( + '[repo-file-watcher] Repo directory unavailable — skipping DB→FS flush to avoid resurrecting it:', + this.repoDir, + ); + return; + } + const entries = await this.getWorkspacesWithMeta(workspaceIds); const currentWorkspaceIds = new Set(entries.map(({ workspace }) => workspace._id)); @@ -514,8 +529,20 @@ class RepoFileWatcher { const hash = contentHash(yamlContent); + // The hash cache only tells us the DB side hasn't changed since our + // last write — it says nothing about whether that write still exists + // on disk. If the repo folder was deleted and came back (e.g. via + // relocate/adopt, or a drive remount), the file may be gone even + // though its content hash is unchanged; skipping the write here would + // leave it missing indefinitely. if (this.lastWrittenHash.get(absPath) === hash) { - continue; + const stillOnDisk = await fs.promises + .access(absPath) + .then(() => true) + .catch(() => false); + if (stillOnDisk) { + continue; + } } await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 5132b028f8..cd93ca4cf1 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -572,7 +572,27 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject label: 'Open folder', isDisabled: !repoPath, icon: 'folder-open', - action: () => window.shell.openPath(repoPath), + action: async () => { + if (!gitRepository?._id) { + return; + } + // Resolve (and confirm it still exists) before opening — unlike a plain + // `window.shell.openPath`, this never recreates a folder that was + // renamed, moved, or deleted outside Insomnia. + const result = await window.main.git.resolveGitRepoFolderPath({ gitRepositoryId: gitRepository._id }); + if ('errors' in result && result.errors) { + showToast({ + icon, + title: 'Folder not found', + description: result.errors.join(', '), + status: 'error', + }); + return; + } + if (result.path) { + window.shell.openPath(result.path); + } + }, }, { id: 'branches', diff --git a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index baf90aea45..86a4ae2c29 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx @@ -1358,7 +1358,22 @@ const ManualCommitForm: FC = ({ - {projectData.cloneParentDir && ( +
+
+ +
+ {projectData.cloneParentDir ? ( + + ) : ( +
+ Managed by Insomnia (default location) +
+ )} - )} + {projectData.cloneParentDir && ( + + )} +
+ {projectData.cloneParentDir && ( + setProjectData(prev => ({ ...prev, cloneFolderName: v.replace(/[/\\]/g, '') }))} + /> + )}
)} diff --git a/packages/insomnia/src/ui/components/project/project-create-form.tsx b/packages/insomnia/src/ui/components/project/project-create-form.tsx index f985e6aefd..81d0d92255 100644 --- a/packages/insomnia/src/ui/components/project/project-create-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-create-form.tsx @@ -15,10 +15,10 @@ import { GitRepoScanResult } from '~/ui/components/project/git-repo-scan-result' import { ProjectTypeSelect } from '~/ui/components/project/project-type-select'; import { ProjectTypeWarning } from '~/ui/components/project/project-type-warning'; import { - deriveRepoName, getLastCloneParentDir, type ProjectData, type ProjectType, + resolveCloneFolderName, useActiveView, } from '~/ui/components/project/utils'; import { useIsGitSyncEnabled } from '~/ui/hooks/use-organization-features'; @@ -141,10 +141,14 @@ export const ProjectCreateForm: FC = ({ } // For a custom clone location, the picked folder is the parent — clone into - // `/`, matching `git clone` behaviour. + // `/`, matching `git clone` behaviour. The folder name + // defaults to the repo's own name but can be overridden in the form. const directory = storageType === 'git' && !isGitOpen && !projectData.connectRepositoryLater && projectData.cloneParentDir - ? window.path.join(projectData.cloneParentDir, deriveRepoName(projectData.uri)) + ? window.path.join( + projectData.cloneParentDir, + resolveCloneFolderName(projectData.cloneFolderName, projectData.uri), + ) : undefined; newProjectFetcher.submit({ diff --git a/packages/insomnia/src/ui/components/project/project-settings-form.tsx b/packages/insomnia/src/ui/components/project/project-settings-form.tsx index 3ee2b2f0b8..550871365a 100644 --- a/packages/insomnia/src/ui/components/project/project-settings-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-settings-form.tsx @@ -34,7 +34,7 @@ import { GitRepoForm } from '~/ui/components/project/git-repo-form'; import { GitRepoScanResult } from '~/ui/components/project/git-repo-scan-result'; import { ProjectTypeSelect } from '~/ui/components/project/project-type-select'; import { ProjectTypeWarning } from '~/ui/components/project/project-type-warning'; -import { deriveRepoName, useActiveView } from '~/ui/components/project/utils'; +import { useActiveView } from '~/ui/components/project/utils'; import { useIsLightTheme } from '~/ui/hooks/theme'; import { useIsGitSyncEnabled } from '~/ui/hooks/use-organization-features'; import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path'; @@ -103,6 +103,7 @@ export const ProjectSettingsForm: FC = ({ }, [project, storageType]); const [error, setError] = useState(null); + const [relocateSuccessMessage, setRelocateSuccessMessage] = useState(null); const [isGitCredentialInvalid, setIsGitCredentialInvalid] = useState(false); const [projectData, setProjectData] = useState<{ @@ -155,13 +156,18 @@ export const ProjectSettingsForm: FC = ({ }, [onDirtyChange, changedFieldCount]); useEffect(() => { - if ( - relocateFetcher.state === 'idle' && - relocateFetcher.data && - 'errors' in relocateFetcher.data && - relocateFetcher.data.errors - ) { + if (relocateFetcher.state !== 'idle' || !relocateFetcher.data) { + return; + } + if ('errors' in relocateFetcher.data && relocateFetcher.data.errors) { setError(relocateFetcher.data.errors.join(', ')); + setRelocateSuccessMessage(null); + } else if ('directory' in relocateFetcher.data && relocateFetcher.data.directory) { + // The move already happened at this point — it isn't gated by the + // "Update" button below, which only tracks name/author/storage-type + // changes. Confirm it explicitly so the button staying disabled doesn't + // read as "nothing happened". + setRelocateSuccessMessage(`Repository moved to ${relocateFetcher.data.directory}`); } }, [relocateFetcher.data, relocateFetcher.state]); @@ -252,18 +258,26 @@ export const ProjectSettingsForm: FC = ({ if (!project || !gitRepository) { return; } + // Start browsing from the current parent folder, not $HOME — makes it easy + // to spot a sibling folder the repo was renamed/moved to (the main reason + // to use this when the stored path is broken). + const currentParentDir = repoPath ? window.path.dirname(repoPath) : window.app.getPath('home'); const picked = await selectFileOrFolder({ itemTypes: ['directory'], - defaultPath: window.app.getPath('home'), + defaultPath: currentParentDir, }); if (picked.canceled || !picked.filePath) { return; } - // Move into `/`, matching the clone flow. - const folderName = deriveRepoName(gitRepository.uri) || gitRepository._id; - const newDirectory = window.path.join(picked.filePath, folderName); + // The picked folder IS the new location itself, not a parent to nest a + // repo-named subfolder under. That lets this double as "reconnect" when the + // stored path is broken: pick the folder the repo now actually lives in + // (already containing its .git data) and relocateGitRepoAction adopts it in + // place instead of trying to move files into/over it. + const newDirectory = picked.filePath; setError(null); + setRelocateSuccessMessage(null); relocateFetcher.submit({ gitRepositoryId: gitRepository._id, projectId: project._id, @@ -271,6 +285,30 @@ export const ProjectSettingsForm: FC = ({ }); }; + const [isOpeningRepoFolder, setIsOpeningRepoFolder] = useState(false); + const onOpenRepoInFileSystem = async () => { + if (!gitRepository) { + return; + } + setIsOpeningRepoFolder(true); + setError(null); + try { + // Resolve (and confirm the folder still exists) before opening — unlike a + // plain `window.shell.openPath`, this never recreates a folder that was + // renamed, moved, or deleted outside Insomnia. + const result = await window.main.git.resolveGitRepoFolderPath({ gitRepositoryId: gitRepository._id }); + if ('errors' in result && result.errors) { + setError(result.errors.join(', ')); + return; + } + if (result.path) { + window.shell.openPath(result.path); + } + } finally { + setIsOpeningRepoFolder(false); + } + }; + const showGitRepoForm = storageType === 'git' && ((isGitSyncEnabled && isSwitchingStorageType(project!, storageType)) || @@ -333,6 +371,13 @@ export const ProjectSettingsForm: FC = ({ )} + {relocateSuccessMessage && ( +
+ + {relocateSuccessMessage} +
+ )} + {/* Important Note: We want to keep the state of the components so we only hide the contents */}
= ({
diff --git a/packages/insomnia/src/ui/components/project/utils.test.ts b/packages/insomnia/src/ui/components/project/utils.test.ts new file mode 100644 index 0000000000..97b0fbbabe --- /dev/null +++ b/packages/insomnia/src/ui/components/project/utils.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveRepoName, resolveCloneFolderName } from './utils'; + +describe('deriveRepoName', () => { + it('derives the repo name from a .git URL', () => { + expect(deriveRepoName('https://github.com/organization/repo-name.git')).toBe('repo-name'); + }); + + it('derives the repo name from a URL without a .git suffix', () => { + expect(deriveRepoName('https://github.com/organization/repo-name')).toBe('repo-name'); + }); + + it('strips a trailing slash before deriving the name', () => { + expect(deriveRepoName('https://github.com/organization/repo-name.git/')).toBe('repo-name'); + }); + + it('strips query strings and fragments', () => { + expect(deriveRepoName('https://github.com/organization/repo-name?foo=bar#section')).toBe('repo-name'); + }); + + it('handles an SSH-style URL', () => { + expect(deriveRepoName('git@github.com:organization/repo-name.git')).toBe('repo-name'); + }); + + it('falls back to "repository" for an empty/undefined URL', () => { + expect(deriveRepoName('')).toBe('repository'); + expect(deriveRepoName()).toBe('repository'); + }); + + it('falls back to "repository" for a URL with no usable name segment', () => { + expect(deriveRepoName('https://github.com/organization/.git')).toBe('repository'); + expect(deriveRepoName('https://github.com/organization/.')).toBe('repository'); + }); +}); + +describe('resolveCloneFolderName', () => { + it('uses the explicit override when set', () => { + expect(resolveCloneFolderName('my-custom-folder', 'https://github.com/org/repo-name.git')).toBe( + 'my-custom-folder', + ); + }); + + it('trims whitespace around the override', () => { + expect(resolveCloneFolderName(' my-custom-folder ', 'https://github.com/org/repo-name.git')).toBe( + 'my-custom-folder', + ); + }); + + // Regression: a whitespace-only override must not produce a blank/invalid + // folder name — fall back to the derived repo name instead. + it('falls back to the derived repo name when the override is whitespace-only', () => { + expect(resolveCloneFolderName(' ', 'https://github.com/org/repo-name.git')).toBe('repo-name'); + }); + + // Regression: a bare "." or ".." would join into the current/parent + // directory of the chosen clone location instead of a new folder inside + // it — must fall back to the derived repo name instead, same as + // deriveRepoName's own guard. + it('falls back to the derived repo name when the override is "." or ".."', () => { + expect(resolveCloneFolderName('.', 'https://github.com/org/repo-name.git')).toBe('repo-name'); + expect(resolveCloneFolderName('..', 'https://github.com/org/repo-name.git')).toBe('repo-name'); + }); + + // Regression: an override containing a path separator would let the clone + // land somewhere other than a single new folder directly inside the chosen + // parent — must fall back to the derived repo name instead. + it('falls back to the derived repo name when the override contains a path separator', () => { + expect(resolveCloneFolderName('foo/bar', 'https://github.com/org/repo-name.git')).toBe('repo-name'); + expect(resolveCloneFolderName('foo\\bar', 'https://github.com/org/repo-name.git')).toBe('repo-name'); + }); + + it('falls back to the derived repo name when no override is given', () => { + expect(resolveCloneFolderName(undefined, 'https://github.com/org/repo-name.git')).toBe('repo-name'); + }); + + it('falls back to "repository" when there is neither an override nor a usable URL', () => { + expect(resolveCloneFolderName()).toBe('repository'); + }); +}); diff --git a/packages/insomnia/src/ui/components/project/utils.tsx b/packages/insomnia/src/ui/components/project/utils.tsx index a48aaff3b5..613c03c776 100644 --- a/packages/insomnia/src/ui/components/project/utils.tsx +++ b/packages/insomnia/src/ui/components/project/utils.tsx @@ -17,10 +17,16 @@ export interface ProjectData { selectedAuthorEmail?: string | null; /** * Optional user-chosen parent folder to clone into. When set, the repo is - * cloned into `/`; when unset, Insomnia manages the - * location. + * cloned into `/`; + * when unset, Insomnia manages the location. */ cloneParentDir?: string; + /** + * Optional user-chosen override for the folder name the repo is cloned into + * (only meaningful alongside `cloneParentDir`). Falls back to + * `deriveRepoName(uri)` (the git repo's own name) when unset. + */ + cloneFolderName?: string; } const LAST_CLONE_DIR_KEY = 'insomnia.git.lastCloneParentDir'; @@ -55,4 +61,23 @@ export const deriveRepoName = (uri?: string): string => { return name; }; +/** + * The folder name a git clone into a custom location should use: the user's + * explicit override (from the "Folder name" field, only shown once a custom + * clone location is picked) when set, otherwise the repo's own name derived + * from its URL. + * + * `.`, `..`, and embedded path separators are rejected (falling back to the + * derived name instead) — same invariant `deriveRepoName` already enforces — + * since a bare `..` here would join into the parent of the chosen clone + * location instead of a new folder inside it. + */ +export const resolveCloneFolderName = (cloneFolderName?: string, uri?: string): string => { + const trimmed = cloneFolderName?.trim(); + if (trimmed && trimmed !== '.' && trimmed !== '..' && !/[/\\]/.test(trimmed)) { + return trimmed; + } + return deriveRepoName(uri); +}; + export type ProjectType = 'local' | 'remote' | 'git';