fix(git): fix folder naming, relocation, and resurrection bugs for git-synced projects (#10428)

This commit is contained in:
Pavlos Koutoglou authored and GitHub committed 2026-08-20 15:16:27 +02:00
1 parent 2d6c58eaa8
commit d693ea90a0
16 files changed
+989 -101

No files matched your search

@@ -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_<slug>_<hex>`
* 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<string> {
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<string, any>();
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
@@ -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 });
}
});
});
+1
View File
@@ -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),
@@ -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<typeof IsomorphicGit>();
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_<hex>` 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');
});
});
@@ -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);
});
});
+203 -17
View File
@@ -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_<hex>` 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<ReturnType<typeof getGitFSClient>>;
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<any> {
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<typeof checkGitRepoDirectoryAction>[0]) =>
checkGitRepoDirectoryAction(options),
);
ipcMainHandle('git.resolveGitRepoFolderPath', (_, options: Parameters<typeof resolveGitRepoFolderPathAction>[0]) =>
resolveGitRepoFolderPathAction(options),
);
ipcMainHandle('git.cleanupGitRepoStorage', (_, options: Parameters<typeof cleanupGitRepoStorageAction>[0]) =>
cleanupGitRepoStorageAction(options),
);
@@ -81,6 +81,7 @@ export type HandleChannels =
| 'git.multipleCommitToGitRepo'
| 'git.openGitRepo'
| 'git.checkGitRepoDirectory'
| 'git.resolveGitRepoFolderPath'
| 'git.pullFromGitRemote'
| 'git.relocateGitRepo'
| 'git.pushToGitRemote'
@@ -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;
@@ -470,6 +470,21 @@ class RepoFileWatcher {
* blocking import problem that the user must resolve first.
*/
private async flushWorkspacesToDisk(workspaceIds?: Set<string>): Promise<void> {
// 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 });
@@ -572,7 +572,27 @@ export const GitProjectSyncDropdown: FC<Props> = ({ 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',
@@ -1358,7 +1358,22 @@ const ManualCommitForm: FC<ManualCommitFormProps> = ({
</Button>
<TooltipTrigger>
<Button
onPress={() => window.shell.openPath(repoPath)}
onPress={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: 'exclamation-triangle', title: 'Folder not found', description: result.errors.join(', '), status: 'error' });
return;
}
if (result.path) {
window.shell.openPath(result.path);
}
}}
className="flex items-center justify-center rounded-xs p-1 hover:bg-(--hl-xs)"
aria-label="Open in file system"
>
@@ -30,7 +30,14 @@ import { showSettingsModal } from '~/ui/components/modals/settings-modal';
import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder';
import { ErrorBoundary } from '../error-boundary';
import { type ActiveView, deriveRepoName, getLastCloneParentDir, type ProjectData, setLastCloneParentDir } from './utils';
import {
type ActiveView,
deriveRepoName,
getLastCloneParentDir,
type ProjectData,
resolveCloneFolderName,
setLastCloneParentDir,
} from './utils';
const { isGitCredentialsV2, isOAuthCredential } = models.gitCredentials;
@@ -340,50 +347,65 @@ export const GitRepoForm: FC<Props> = ({
/>
</div>
<div className={isCredentialInvalid ? 'hidden' : 'flex flex-col gap-2 px-0.5'}>
<Label className="text-sm text-(--color-font)">Clone location</Label>
<div className="flex items-center gap-2">
{projectData.cloneParentDir ? (
<MiddleTruncate
value={window.path.join(projectData.cloneParentDir, deriveRepoName(projectData.uri))}
className="h-(--line-height-xs) flex-1 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)"
/>
) : (
<div className="flex h-(--line-height-xs) flex-1 items-center truncate rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)">
Managed by Insomnia (default location)
</div>
)}
<Button
type="button"
onPress={async () => {
const defaultPath =
projectData.cloneParentDir ||
getLastCloneParentDir() ||
window.path.join(window.app.getPath('home'), 'Insomnia');
const { canceled, filePath } = await selectFileOrFolder({
itemTypes: ['directory'],
defaultPath,
});
if (canceled || !filePath) {
return;
}
setLastCloneParentDir(filePath);
setProjectData(prev => ({ ...prev, cloneParentDir: filePath }));
}}
className="flex h-(--line-height-xs) items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-3 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
>
Choose folder
</Button>
{projectData.cloneParentDir && (
<div className={isCredentialInvalid ? 'hidden' : 'flex flex-col gap-3 px-0.5'}>
<div className="flex flex-col gap-2">
<Label className="text-sm text-(--color-font)">Clone location</Label>
<div className="flex items-center gap-2">
{projectData.cloneParentDir ? (
<MiddleTruncate
value={window.path.join(
projectData.cloneParentDir,
resolveCloneFolderName(projectData.cloneFolderName, projectData.uri),
)}
className="h-(--line-height-xs) flex-1 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)"
/>
) : (
<div className="flex h-(--line-height-xs) flex-1 items-center truncate rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)">
Managed by Insomnia (default location)
</div>
)}
<Button
type="button"
onPress={() => setProjectData(prev => ({ ...prev, cloneParentDir: undefined }))}
onPress={async () => {
const defaultPath =
projectData.cloneParentDir ||
getLastCloneParentDir() ||
window.path.join(window.app.getPath('home'), 'Insomnia');
const { canceled, filePath } = await selectFileOrFolder({
itemTypes: ['directory'],
defaultPath,
});
if (canceled || !filePath) {
return;
}
setLastCloneParentDir(filePath);
setProjectData(prev => ({ ...prev, cloneParentDir: filePath }));
}}
className="flex h-(--line-height-xs) items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-3 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
>
Use default
Choose folder
</Button>
)}
{projectData.cloneParentDir && (
<Button
type="button"
onPress={() => setProjectData(prev => ({ ...prev, cloneParentDir: undefined, cloneFolderName: undefined }))}
className="flex h-(--line-height-xs) items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-3 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
>
Use default
</Button>
)}
</div>
</div>
{projectData.cloneParentDir && (
<Input
label="Folder name"
description="The name of the folder created inside the clone location above."
value={projectData.cloneFolderName ?? deriveRepoName(projectData.uri)}
name="cloneFolderName"
placeholder={deriveRepoName(projectData.uri)}
onChange={v => setProjectData(prev => ({ ...prev, cloneFolderName: v.replace(/[/\\]/g, '') }))}
/>
)}
</div>
</Form>
)}
@@ -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<Props> = ({
}
// For a custom clone location, the picked folder is the parent — clone into
// `<parent>/<repo-name>`, matching `git clone` behaviour.
// `<parent>/<folder-name>`, 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({
@@ -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<Props> = ({
}, [project, storageType]);
const [error, setError] = useState<string | null>(null);
const [relocateSuccessMessage, setRelocateSuccessMessage] = useState<string | null>(null);
const [isGitCredentialInvalid, setIsGitCredentialInvalid] = useState(false);
const [projectData, setProjectData] = useState<{
@@ -155,13 +156,18 @@ export const ProjectSettingsForm: FC<Props> = ({
}, [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<Props> = ({
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 `<chosen-parent>/<repo-name>`, 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<Props> = ({
});
};
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<Props> = ({
</div>
)}
{relocateSuccessMessage && (
<div className="flex items-center gap-2 rounded-xs bg-(--color-surprise)/50 px-2 py-1 text-sm text-(--color-font)">
<Icon icon="circle-check" />
<span>{relocateSuccessMessage}</span>
</div>
)}
{/* Important Note: We want to keep the state of the components so we only hide the contents */}
<div
className={`flex w-full flex-col justify-start gap-4 pb-2 text-left ${activeView === 'project' ? '' : 'hidden'}`}
@@ -466,7 +511,8 @@ export const ProjectSettingsForm: FC<Props> = ({
</Button>
<TooltipTrigger>
<Button
onPress={() => window.shell.openPath(repoPath)}
onPress={onOpenRepoInFileSystem}
isDisabled={isOpeningRepoFolder}
className="flex items-center justify-center rounded-xs p-1 hover:bg-(--hl-xs)"
aria-label="Open in file system"
>
@@ -495,7 +541,7 @@ export const ProjectSettingsForm: FC<Props> = ({
offset={8}
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-3 py-2 text-sm text-(--color-font) shadow-lg"
>
Move to another folder
Move to another folder (applies immediately)
</Tooltip>
</TooltipTrigger>
</div>
@@ -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');
});
});
@@ -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 `<cloneParentDir>/<repo-name>`; when unset, Insomnia manages the
* location.
* cloned into `<cloneParentDir>/<cloneFolderName || deriveRepoName(uri)>`;
* 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';