Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a035545
tech_design
gatzjames Jun 18, 2026
0b3b3e5
tech-doc
gatzjames Jun 18, 2026
a01be40
add directory to git repo model
gatzjames Jun 18, 2026
d4ed34f
tech doc
gatzjames Jun 18, 2026
0fea557
tech doc
gatzjames Jun 18, 2026
bcce126
use directory picker to select an existing repo to clone from/to
gatzjames Jun 18, 2026
961a458
tech doc
gatzjames Jun 18, 2026
fcae2cc
Open git repo
gatzjames Jun 18, 2026
c6c41d0
tech doc
gatzjames Jun 19, 2026
1a9ce94
Implement Git project local storage features and add e2e tests
gatzjames Jun 19, 2026
dc9605a
tech doc
gatzjames Jun 19, 2026
735793b
Implement folder opening as Git projects with user trust confirmation
gatzjames Jun 19, 2026
ae78e05
Add Git credential selection to project creation form and enhance rep…
gatzjames Jun 24, 2026
91befeb
tech doc
gatzjames Jun 24, 2026
9a52580
refactor: clean up code and remove references to GIT_LOCAL_REPOS_DESI…
gatzjames Jun 24, 2026
3699cb6
fix: handle optional author name in Git credential display
gatzjames Jun 30, 2026
0997ce0
feat: enhance Git credential handling for local repositories
gatzjames Jun 30, 2026
e056160
feat: enhance Git project folder handling and improve test descriptions
gatzjames Jun 30, 2026
fd077c8
fix: update Git project mode button copy
gatzjames Jun 30, 2026
4aebe12
fix: align control heights and styling in Git clone form
gatzjames Jun 30, 2026
713862f
feat: show clone target path and remember last clone folder
gatzjames Jun 30, 2026
5651ed7
fix: reorder and align Open local folder input layout
gatzjames Jun 30, 2026
8a8a013
feat: warn when opening a folder already used by a project
gatzjames Jun 30, 2026
fd16250
fix: top-align empty organization view and scroll the full page
gatzjames Jun 30, 2026
8d2f624
fix: align project modal to top and match folder description color
gatzjames Jul 1, 2026
eef328b
Merge branch 'develop' into feat/git-project-local-storage
pavkout Jul 7, 2026
a1879bd
Potential fix for pull request finding
CurryYangxx Jul 7, 2026
3b847eb
fix: always use getRepoBaseDir
CurryYangxx Jul 7, 2026
3fa14e2
fix comment
CurryYangxx Jul 7, 2026
d331d09
delete duplicate code
CurryYangxx Jul 7, 2026
978bd5c
fix: test
CurryYangxx Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/insomnia-data/node-src/services/git-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export async function getAllByCredentialId(credentialsId: string) {
return db.find<GitRepository>(type, { credentialsId });
}

export async function getByDirectory(directory: string) {
return db.findOne<GitRepository>(type, { directory });
}

export function update(repo: GitRepository, patch: Partial<GitRepository>) {
return db.docUpdate<GitRepository>(repo, patch);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/insomnia-data/src/models/git-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function init(): BaseGitRepository {
hasUnpushedChanges: false,
uriNeedsMigration: true,
repoMigrationVersion: 0,
directory: null,
};
}

Expand Down Expand Up @@ -69,6 +70,16 @@ export interface BaseGitRepository {
* for version-rollback scenarios.
*/
repoMigrationVersion: number;
/**
* Absolute path to a user-chosen location on the local filesystem where this
* repository's working tree and .git directory live.
*
* `null` (the default) means the repository is stored in the app-managed
* location: `{INSOMNIA_DATA_PATH || userData}/version-control/git/{_id}`.
* Insomnia owns that managed folder. When `directory` is set, the user owns
* the folder and Insomnia must not delete it on project removal.
*/
directory: string | null;
}

export const isGitRepository = (model: Pick<BaseModel, 'type'>): model is GitRepository => model.type === type;
Expand Down
59 changes: 55 additions & 4 deletions packages/insomnia-smoke-test/playwright/pages/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ export class ProjectPage extends BasePage {
}

const breadcrumbLink = this.page.getByTestId('workspace-breadcrumb-level-0').getByRole('link');
await ((await breadcrumbLink.isVisible()) ? breadcrumbLink.click() : this.sidebar.projectRow(projectName).click());
await ((await breadcrumbLink.isVisible())
? breadcrumbLink.click()
: this.sidebar.projectRow(projectName).click());

try {
await this.page.waitForURL(this.projectDashboardUrl, { timeout: 5000, waitUntil: 'commit' });
Expand Down Expand Up @@ -103,9 +105,7 @@ export class ProjectPage extends BasePage {
async createCollection(name = 'My Collection'): Promise<void> {
await this.selectCreateInProjectType('Collection');
await this.page.getByRole('dialog').waitFor({ state: 'visible' });
const nameInput = this.page
.getByRole('dialog')
.getByPlaceholder('Enter a name for your Request Collection');
const nameInput = this.page.getByRole('dialog').getByPlaceholder('Enter a name for your Request Collection');
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(name);
await this.page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
Expand Down Expand Up @@ -151,6 +151,57 @@ export class ProjectPage extends BasePage {
await this.page.getByRole('button', { name: 'Create', exact: true }).click();
}

/**
* Selects an existing local folder in the Git project form without opening it.
* The native directory picker is mocked to return `folderPath`.
*/
async chooseGitProjectFolderForOpen(name: string, folderPath: string): Promise<void> {
await mockOpenDialogForDirectory(this.app, folderPath);
await this.page.getByRole('button', { name: 'Create new Project' }).click();
await this.setProjectName(name);
await this.selectStorageType('git');
await this.page.getByRole('button', { name: 'Open local folder' }).click();
await this.page.getByRole('button', { name: 'Choose folder' }).click();
}

/**
* Opens an existing local folder as a Git project (no clone). The native
* directory picker is mocked to return `folderPath`. If the folder isn't a git
* repo, the app runs `git init`.
*/
async openGitProjectFromFolder(name: string, folderPath: string): Promise<void> {
await this.chooseGitProjectFolderForOpen(name, folderPath);
await this.page.getByRole('button', { name: 'Open', exact: true }).click();
// Confirm the "Do you trust this folder?" dialog before the folder is opened/initialized.
await this.page.getByRole('button', { name: 'Open folder' }).click();
}

/**
* Clones a repo into a user-chosen parent folder (the picker is mocked to
* return `parentFolderPath`). The repo is cloned into
* `<parentFolderPath>/<repo-name>`. Requires the git test server + credential.
*/
async cloneGitProjectIntoFolder(name: string, parentFolderPath: string): Promise<void> {
await mockOpenDialogForDirectory(this.app, parentFolderPath);
await this.sidebar.clickNewProject();
await this.page.getByRole('textbox', { name: 'Project name' }).click();
await this.page.getByRole('textbox', { name: 'Project name' }).press('ControlOrMeta+a');
await this.page.getByRole('textbox', { name: 'Project name' }).fill(name);
await this.page.getByText('Git Sync').click();
await this.page.getByRole('button', { name: 'Git Credentials Authorized as' }).click();
await this.page.getByRole('option', { name: 'Custom Git Credential' }).click();
await this.page.getByRole('textbox', { name: 'Repository URL' }).click();
// Use the reachable git test server URL so remote branches can be listed.
// deriveRepoName() still yields "git-server" from this URL.
await this.page.getByRole('textbox', { name: 'Repository URL' }).fill('http://localhost:4010/git/git-server.git');
await this.page.getByRole('button', { name: 'Show suggestions Branch' }).click();
await this.page.getByRole('option', { name: 'master' }).click();
// Pick the custom clone destination before scanning.
await this.page.getByRole('button', { name: 'Choose folder' }).click();
await this.page.getByRole('button', { name: 'Scan for files' }).click();
await this.page.getByRole('button', { name: 'Create Blank Project' }).click();
}

async createGitSyncProject(name = 'My Git Project'): Promise<void> {
await this.sidebar.clickNewProject();
await this.page.getByRole('textbox', { name: 'Project name' }).click();
Expand Down
86 changes: 86 additions & 0 deletions packages/insomnia-smoke-test/tests/smoke/git-local-repos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { expect } from '@playwright/test';

import type { InsomniaApp } from '../../playwright/pages';
import { test } from '../../playwright/test';

// Verifies repositories can live in user-chosen folders on disk:
// - opening an existing/plain folder as a Git project (runs `git init` if needed)
// - cloning into a user-chosen destination folder

const makeTempDir = (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));

test.describe('Git repositories in user-chosen folders', () => {
test.slow();

test('opens a plain local folder as a Git project and initializes git', async ({ insomnia, page }) => {
const folder = makeTempDir('insomnia-open-folder-');
try {
// The folder is not a git repo yet — no .git present.
expect.soft(fs.existsSync(path.join(folder, '.git'))).toBe(false);

await insomnia.projectPage.openGitProjectFromFolder('Opened Folder Project', folder);

// `git init` must have run inside the chosen folder.
await expect.poll(() => fs.existsSync(path.join(folder, '.git')), { timeout: 30_000 }).toBe(true);

// The project landed — no error banner.
await expect.soft(page.getByText('Opened Folder Project')).toBeVisible();
} finally {
fs.rmSync(folder, { recursive: true, force: true });
}
});

test('blocks opening the same folder twice (collision guard)', async ({ insomnia, page }) => {
const folder = makeTempDir('insomnia-open-collision-');
try {
await insomnia.projectPage.openGitProjectFromFolder('First Adoption', folder);
await expect.poll(() => fs.existsSync(path.join(folder, '.git')), { timeout: 30_000 }).toBe(true);

// Back to the project dashboard, then try to adopt the same folder again.
await insomnia.projectPage.chooseGitProjectFolderForOpen('Second Adoption', folder);

// The UI blocks before submit and surfaces the already-connected folder.
await expect.soft(page.getByText(/already connected to this folder/i)).toBeVisible();
await expect.soft(page.getByRole('button', { name: 'Open', exact: true })).toBeDisabled();
} finally {
fs.rmSync(folder, { recursive: true, force: true });
}
});
});

test.describe('Git clone into a user-chosen folder', () => {
test.slow();

test.beforeEach(async ({ insomnia, request }) => {
await request.post('http://127.0.0.1:4010/v1/test-utils/git/setup');
await addAccessTokenGitCredential(insomnia);
});

test.afterEach(async ({ request }) => {
await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup');
});

test('clones into <chosen-parent>/<repo-name>', async ({ insomnia }) => {
const parent = makeTempDir('insomnia-clone-dest-');
try {
await insomnia.projectPage.cloneGitProjectIntoFolder('Cloned Into Folder', parent);

// The repo URL is "git-server.git" → derived repo name "git-server".
await expect.poll(() => fs.existsSync(path.join(parent, 'git-server', '.git')), { timeout: 60_000 }).toBe(true);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
});

async function addAccessTokenGitCredential(insomnia: InsomniaApp) {
await insomnia.statusbar.openPreferences();
await insomnia.preferencesPage.switchToPreferenceTab('Credentials');
await insomnia.preferencesPage.credentialsTab.addAccessTokenGitCredential();
await expect.soft(insomnia.page.getByRole('row', { name: 'Custom Git Credential' })).toBeVisible();
await insomnia.preferencesPage.closePreferences();
}
9 changes: 9 additions & 0 deletions packages/insomnia/electron-builder.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ const config = {
NSRequiresAquaSystemAppearance: false,
NSLocalNetworkUsageDescription:
'Insomnia needs permission to connect to local APIs and development servers such as localhost, 127.0.0.1, or other LAN hosts.',
// Allow Finder "Open With → Insomnia" on folders so they can be opened as Git projects.
CFBundleDocumentTypes: [
{
CFBundleTypeName: 'Folder',
CFBundleTypeRole: 'Viewer',
LSHandlerRank: 'Alternate',
LSItemContentTypes: ['public.folder'],
},
],
},
// If this step fails its possible apple has new license terms which need to be accepted by logging into https://developer.apple.com/account
notarize: true,
Expand Down
38 changes: 34 additions & 4 deletions packages/insomnia/src/entry.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,37 @@ app.on('activate', (_error, hasVisibleWindows) => {
}
});

// When a folder path is opened from the OS (CLI arg, Finder "Open With", etc.),
// normalise it into the open-folder deep link so the renderer has a single,
// well-formed entry point. Returns null when the path isn't an existing folder.
const toOpenFolderDeepLink = async (rawPath: string): Promise<string | null> => {
try {
if (!rawPath || !path.isAbsolute(rawPath)) {
return null;
}
const stats = await fs.stat(rawPath);
if (!stats.isDirectory()) {
return null;
}
return `insomnia://app/open-folder?path=${encodeURIComponent(rawPath)}`;
} catch {
return null;
}
};

const _launchApp = async () => {
await _trackStats();
let window: BrowserWindow;
// Handle URLs sent via command line args
ipcMainOnce('halfSecondAfterAppStart', () => {
ipcMainOnce('halfSecondAfterAppStart', async () => {
console.log('[main] Window ready, handling command line arguments', process.argv);
const args = process.argv.slice(1).filter(a => a !== '.');
console.log('[main] Check args and create windows', args);
if (args.length) {
window = windowUtils.createWindowsAndReturnMain();
window.webContents.send('shell:open', args.join(','));
// A folder path (e.g. `insomnia /path/to/repo`) opens it as a Git project.
const folderDeepLink = await toOpenFolderDeepLink(args[args.length - 1]);
window.webContents.send('shell:open', folderDeepLink || args.join(','));
}
});
// Disable deep linking in playwright e2e tests in order to run multiple tests in parallel
Expand All @@ -246,7 +266,7 @@ const _launchApp = async () => {
app.quit();
} else {
// Called when second instance launched with args (Windows/Linux)
app.on('second-instance', (_1, args) => {
app.on('second-instance', async (_1, args) => {
console.log('[main] Second instance listener received:', args.join('||'));
window = windowUtils.createWindowsAndReturnMain();
if (window) {
Expand All @@ -257,7 +277,8 @@ const _launchApp = async () => {
}
const lastArg = args.slice(-1).join(',');
console.log('[main] Open Deep Link URL sent from second instance', lastArg);
window.webContents.send('shell:open', lastArg);
const folderDeepLink = await toOpenFolderDeepLink(lastArg);
window.webContents.send('shell:open', folderDeepLink || lastArg);
});
window = windowUtils.createWindowsAndReturnMain();

Expand All @@ -278,6 +299,15 @@ const _launchApp = async () => {
app.on('open-url', (_event, url) => {
openDeepLinkUrl(url);
});
// macOS Finder "Open With" → Insomnia for a folder (declared as a folder
// document type in electron-builder.config.js). Only folders are adopted.
app.on('open-file', async (event, filePath) => {
event.preventDefault();
const folderDeepLink = await toOpenFolderDeepLink(filePath);
if (folderDeepLink) {
openDeepLinkUrl(folderDeepLink);
}
});
ipcMainOn('openDeepLink', (_event, url) => {
openDeepLinkUrl(url);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/insomnia/src/entry.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ const git: GitServiceAPI = {
gitChangesLoader: options => invokeWithNormalizedError('git.gitChangesLoader', options),
canPushLoader: options => invokeWithNormalizedError('git.canPushLoader', options),
cloneGitRepo: options => invokeWithNormalizedError('git.cloneGitRepo', options),
openGitRepo: options => invokeWithNormalizedError('git.openGitRepo', options),
checkGitRepoDirectory: options => invokeWithNormalizedError('git.checkGitRepoDirectory', options),
cleanupGitRepoStorage: options => invokeWithNormalizedError('git.cleanupGitRepoStorage', options),
relocateGitRepo: options => invokeWithNormalizedError('git.relocateGitRepo', options),
initGitRepoClone: options => invokeWithNormalizedError('git.initGitRepoClone', options),
updateGitRepo: options => invokeWithNormalizedError('git.updateGitRepo', options),
resetGitRepo: options => invokeWithNormalizedError('git.resetGitRepo', options),
Expand Down
Loading
Loading