mirror of
https://github.com/Kong/insomnia.git
synced 2026-09-21 13:45:13 -04:00
fix(git): fix folder naming, relocation, and resurrection bugs for git-synced projects (#10428)
This commit is contained in:
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user