diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index b03fb8e51..a890f1979 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import {
updateCookies,
- updatePreferences
+ updatePreferences,
+ setGitVersion
} from 'providers/ReduxStore/slices/app';
import {
addTab
@@ -329,6 +330,10 @@ const useIpcEvents = () => {
dispatch(updateCollectionLoadingState(val));
});
+ const gitVersionListener = ipcRenderer.on('main:git-version', (val) => {
+ dispatch(setGitVersion(val));
+ });
+
return () => {
removeCollectionTreeUpdateListener();
removeApiSpecTreeUpdateListener();
@@ -360,6 +365,7 @@ const useIpcEvents = () => {
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();
+ gitVersionListener();
};
}, [isElectron]);
};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index 6814b13c2..e96dc26f7 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -48,6 +48,8 @@ const initialState = {
},
cookies: [],
taskQueue: [],
+ gitOperationProgress: {},
+ gitVersion: null,
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
},
@@ -123,6 +125,19 @@ export const appSlice = createSlice({
toggleSidebarCollapse: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
+ updateGitOperationProgress: (state, action) => {
+ const { uid, data } = action.payload;
+ if (!state.gitOperationProgress[uid]) {
+ state.gitOperationProgress[uid] = { progressData: [] };
+ }
+ state.gitOperationProgress[uid].progressData.push(data);
+ },
+ removeGitOperationProgress: (state, action) => {
+ delete state.gitOperationProgress[action.payload];
+ },
+ setGitVersion: (state, action) => {
+ state.gitVersion = action.payload;
+ },
setClipboard: (state, action) => {
// Update clipboard UI state
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
@@ -164,6 +179,9 @@ export const {
updateSystemProxyVariables,
updateGenerateCode,
toggleSidebarCollapse,
+ updateGitOperationProgress,
+ removeGitOperationProgress,
+ setGitVersion,
setClipboard
} = appSlice.actions;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index 29ebb38db..fdadf4746 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -10,6 +10,7 @@ import trim from 'lodash/trim';
import path, { normalizePath } from 'utils/common/path';
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
+import IpcErrorModal from 'components/Errors/IpcErrorModal/index';
import {
findCollectionByUid,
findEnvironmentInCollection,
@@ -2685,19 +2686,22 @@ export const importCollection = (collection, collectionLocation, options = {}) =
try {
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
+ const isMultiple = Array.isArray(collection);
- const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
+ const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
+ const importedPaths = result.success.items;
- if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
- const workspaceCollection = {
- name: collection.name,
- path: collectionPath
- };
-
- await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
+ if (importedPaths.length > 0 && activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
+ for (const importedItem of importedPaths) {
+ const workspaceCollection = {
+ name: importedItem.name,
+ path: importedItem.path
+ };
+ await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
+ }
}
- resolve(collectionPath);
+ resolve(isMultiple ? importedPaths : importedPaths[0]);
} catch (error) {
reject(error);
}
@@ -3027,6 +3031,34 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch,
});
};
+export const cloneGitRepository = (data) => (dispatch, getState) => {
+ const { ipcRenderer } = window;
+ return new Promise((resolve, reject) => {
+ ipcRenderer
+ .invoke('renderer:clone-git-repository', data)
+ .then((res) => {
+ console.log('clone done', res);
+ })
+ .then(resolve)
+ .catch((err) => {
+ toast.custom(
);
+ reject();
+ });
+ });
+};
+
+export const scanForBrunoFiles = (dir) => (dispatch, getState) => {
+ const { ipcRenderer } = window;
+ return new Promise((resolve, reject) => {
+ ipcRenderer
+ .invoke('renderer:scan-for-bruno-files', dir)
+ .then(resolve)
+ .catch((err) => {
+ reject();
+ });
+ });
+};
+
/**
* Close tabs and delete any transient request files from the filesystem.
* This thunk wraps the closeTabs reducer to handle transient file cleanup automatically.
diff --git a/packages/bruno-app/src/utils/git/index.js b/packages/bruno-app/src/utils/git/index.js
new file mode 100644
index 000000000..77a5762a5
--- /dev/null
+++ b/packages/bruno-app/src/utils/git/index.js
@@ -0,0 +1,63 @@
+import gitUrlParse from 'git-url-parse';
+
+const isGitUrl = (str) => {
+ try {
+ const parsed = gitUrlParse(str);
+
+ if (!parsed) {
+ return false;
+ }
+
+ // Validate that it has the essential parts of a git URL and uses valid protocols
+ const validProtocols = ['git', 'ssh', 'http', 'https'];
+ return !!(
+ parsed
+ && parsed.owner
+ && parsed.source
+ && validProtocols.includes(parsed.protocol)
+ );
+ } catch (error) {
+ return false;
+ }
+};
+
+export const getRepoNameFromUrl = (url) => {
+ try {
+ const parsedUrl = gitUrlParse(url);
+ return parsedUrl.name;
+ } catch (error) {
+ throw new Error('Invalid Git URL');
+ }
+};
+
+export const containsGitHubToken = (remoteUrl) => {
+ const GITHUB_TOKEN_REGEX = /(ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]{30,}/;
+ return GITHUB_TOKEN_REGEX.test(remoteUrl);
+};
+
+export const getSafeGitRemoteUrls = (remotes = []) => {
+ const remoteUrls = remotes
+ ?.map((remote) => remote?.refs?.fetch)
+ ?.filter((url) => typeof url === 'string' && url?.trim()?.length > 0);
+
+ const safeRemoteUrls = remoteUrls
+ ?.filter((remoteUrl) => !containsGitHubToken(remoteUrl));
+ return safeRemoteUrls || [];
+};
+
+export const isGitRepositoryUrl = (url) => {
+ try {
+ if (!url || typeof url !== 'string') {
+ return false;
+ }
+
+ // First try the URL as-is
+ if (isGitUrl(url)) {
+ return true;
+ }
+
+ return false;
+ } catch {
+ return false;
+ }
+};
diff --git a/packages/bruno-app/src/utils/git/index.spec.js b/packages/bruno-app/src/utils/git/index.spec.js
new file mode 100644
index 000000000..f371c3d07
--- /dev/null
+++ b/packages/bruno-app/src/utils/git/index.spec.js
@@ -0,0 +1,112 @@
+import { containsGitHubToken, getSafeGitRemoteUrls, isGitRepositoryUrl } from './index';
+
+describe('containsGitHubToken', () => {
+ test('should return true for a URL containing a GitHub token', () => {
+ expect(containsGitHubToken('https://ghp_abcdefgh1234567890abcdefgh12345678@github.com'))
+ .toBe(true);
+ });
+
+ test('should return false for a URL without a GitHub token', () => {
+ expect(containsGitHubToken('https://github.com/user/repo.git'))
+ .toBe(false);
+ });
+
+ test('should return false for an empty string', () => {
+ expect(containsGitHubToken(''))
+ .toBe(false);
+ });
+
+ test('should return false for a null value', () => {
+ expect(containsGitHubToken(null))
+ .toBe(false);
+ });
+
+ test('should return false for a URL with a similar but invalid token', () => {
+ expect(containsGitHubToken('https://ghz_abcdefgh1234567890@github.com'))
+ .toBe(false);
+ });
+});
+
+describe('getSafeGitRemoteUrls', () => {
+ test('should filter out URLs containing GitHub tokens', () => {
+ const remotes = [
+ { refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } },
+ { refs: { fetch: 'https://github.com/user/repo.git' } },
+ { refs: { fetch: 'git@github.com:user/repo.git' } }
+ ];
+ expect(getSafeGitRemoteUrls(remotes)).toEqual([
+ 'https://github.com/user/repo.git',
+ 'git@github.com:user/repo.git'
+ ]);
+ });
+
+ test('should return an empty array if all URLs contain GitHub tokens', () => {
+ const remotes = [
+ { refs: { fetch: 'https://ghp_abcdefgh1234567890abcdefgh12345678@github.com' } },
+ { refs: { fetch: 'https://gho_abcdefgh1234567890abcdefgh12345678@github.com' } }
+ ];
+ expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
+ });
+
+ test('should return an empty array if no valid URLs are present', () => {
+ const remotes = [
+ { refs: { fetch: '' } },
+ { refs: { fetch: null } },
+ { refs: { fetch: undefined } }
+ ];
+ expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
+ });
+
+ test('should return an empty array if input is null or undefined', () => {
+ expect(getSafeGitRemoteUrls(null)).toEqual([]);
+ expect(getSafeGitRemoteUrls(undefined)).toEqual([]);
+ });
+
+ test('should ignore remotes with no fetch property', () => {
+ const remotes = [
+ { refs: {} },
+ {}
+ ];
+ expect(getSafeGitRemoteUrls(remotes)).toEqual([]);
+ });
+});
+
+describe('isGitRepositoryUrl', () => {
+ test('should return true for valid HTTPS GitHub URLs', () => {
+ expect(isGitRepositoryUrl('https://github.com/user/repo.git')).toBe(true);
+ expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true); // automatically adds .git suffix
+ });
+
+ test('should return true for valid SSH GitHub URLs', () => {
+ expect(isGitRepositoryUrl('git@github.com:user/repo.git')).toBe(true);
+ });
+
+ test('should return true for custom Git server URLs', () => {
+ expect(isGitRepositoryUrl('https://git.example.com/user/repo.git')).toBe(true);
+ expect(isGitRepositoryUrl('git@git.example.com:user/repo.git')).toBe(true);
+ });
+
+ test('should return false for invalid URLs', () => {
+ expect(isGitRepositoryUrl('')).toBe(false);
+ expect(isGitRepositoryUrl('not-a-url')).toBe(false);
+ expect(isGitRepositoryUrl('https://example.com')).toBe(false);
+ expect(isGitRepositoryUrl('ftp://github.com/user/repo.git')).toBe(false);
+ });
+
+ test('should return true for HTTPS URLs without .git suffix for valid Git hosts', () => {
+ expect(isGitRepositoryUrl('https://github.com/user/repo')).toBe(true);
+ expect(isGitRepositoryUrl('https://gitlab.com/user/repo')).toBe(true);
+ expect(isGitRepositoryUrl('https://bitbucket.org/user/repo')).toBe(true);
+ });
+
+ test('should return false for null or undefined', () => {
+ expect(isGitRepositoryUrl(null)).toBe(false);
+ expect(isGitRepositoryUrl(undefined)).toBe(false);
+ });
+
+ test('should handle malformed URLs gracefully', () => {
+ expect(isGitRepositoryUrl('https://')).toBe(false);
+ expect(isGitRepositoryUrl('git@')).toBe(false);
+ expect(isGitRepositoryUrl('://invalid')).toBe(false);
+ });
+});
diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js
index 6bf9272a3..3ab032f73 100644
--- a/packages/bruno-app/src/utils/importers/common.js
+++ b/packages/bruno-app/src/utils/importers/common.js
@@ -1,22 +1,31 @@
+import jsyaml from 'js-yaml';
import each from 'lodash/each';
import get from 'lodash/get';
+import filter from 'lodash/filter';
import cloneDeep from 'lodash/cloneDeep';
import { uuid } from 'utils/common';
import { isItemARequest } from 'utils/collections';
import { collectionSchema } from '@usebruno/schema';
import { BrunoError } from 'utils/common/error';
+import { isOpenApiSpec } from './openapi-collection';
+import { isPostmanCollection } from './postman-collection';
+import { isInsomniaCollection } from './insomnia-collection';
-export const validateSchema = (collection = {}) => {
- return new Promise((resolve, reject) => {
- collectionSchema
- .validate(collection)
- .then(() => resolve(collection))
- .catch((err) => {
- console.log(err);
- reject(new BrunoError('The Collection file is corrupted'));
- });
- });
+export const validateSchema = async (collections = []) => {
+ collections = Array.isArray(collections) ? collections : [collections];
+
+ try {
+ await Promise.all(
+ collections.map(async (collection) => {
+ await collectionSchema.validate(collection);
+ })
+ );
+ return collections;
+ } catch (err) {
+ console.log(err);
+ throw new BrunoError('The Collection file is corrupted');
+ }
};
export const updateUidsInCollection = (_collection) => {
@@ -66,6 +75,18 @@ export const updateUidsInCollection = (_collection) => {
return collection;
};
+export const filterItemsInCollection = (collection) => {
+ // this filters out the bruno.json item in older collection exports
+ collection.items = filter(collection.items, (item) => {
+ if (item?.name === 'bruno' && item?.type === 'json') {
+ return false;
+ }
+ return true;
+ });
+
+ return collection;
+};
+
// todo
// need to eventually get rid of supporting old collection app models
// 1. start with making request type a constant fetched from a single place
@@ -156,7 +177,12 @@ export const transformItemsInCollection = (collection) => {
});
};
- transformItems(collection.items);
+ if (Array.isArray(collection)) {
+ collection.forEach((col) => transformItems(col.items));
+ } else {
+ transformItems(collection.items);
+ }
+
return collection;
};
@@ -173,7 +199,38 @@ export const hydrateSeqInCollection = (collection) => {
}
});
};
- hydrateSeq(collection.items);
+
+ if (Array.isArray(collection)) {
+ collection.forEach((col) => hydrateSeq(col.items));
+ } else {
+ hydrateSeq(collection.items);
+ }
return collection;
};
+
+/**
+ * Gets the schema type(postman, insomnia, openapi) of the CollectionJSON data
+ * @param {Object} data - The JSON data to get the type of
+ * @returns {'openapi' | 'postman' | 'insomnia' | 'unknown'} - The type of the CollectionJSON data
+ */
+const getCollectionSpecType = (data) => {
+ return isOpenApiSpec(data) ? 'openapi' : isPostmanCollection(data) ? 'postman' : isInsomniaCollection(data) ? 'insomnia' : 'unknown';
+};
+
+export const fetchAndValidateApiSpecFromUrl = ({ url }) => {
+ const { ipcRenderer } = window;
+ return new Promise((resolve, reject) => {
+ ipcRenderer
+ .invoke('renderer:fetch-api-spec', url)
+ .then((res) => jsyaml.load(res))
+ .then((data) => {
+ const specType = getCollectionSpecType(data);
+ resolve({ data, specType: specType });
+ })
+ .catch((err) => {
+ console.error(err);
+ reject(new BrunoError('Failed to fetch API specification: ' + err.message));
+ });
+ });
+};
diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json
index 754431ba4..3f7ec3701 100644
--- a/packages/bruno-electron/package.json
+++ b/packages/bruno-electron/package.json
@@ -70,6 +70,7 @@
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.14.1",
+ "simple-git": "^3.22.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 6cc1979b8..ee4d12a5c 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -41,6 +41,7 @@ const registerPreferencesIpc = require('./ipc/preferences');
const registerSystemMonitorIpc = require('./ipc/system-monitor');
const registerWorkspaceIpc = require('./ipc/workspace');
const registerApiSpecIpc = require('./ipc/apiSpec');
+const registerGitIpc = require('./ipc/git');
const collectionWatcher = require('./app/collection-watcher');
const WorkspaceWatcher = require('./app/workspace-watcher');
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
@@ -403,6 +404,7 @@ app.on('ready', async () => {
registerNotificationsIpc(mainWindow, collectionWatcher);
registerFilesystemIpc(mainWindow);
registerSystemMonitorIpc(mainWindow, systemMonitor);
+ registerGitIpc(mainWindow);
});
// Quit the app once all windows are closed
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 3e4a8ed02..6f05725f9 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -53,7 +53,8 @@ const {
isValidDotEnvFilename,
isBrunoConfigFile,
isBruEnvironmentConfig,
- isCollectionRootBruFile
+ isCollectionRootBruFile,
+ scanForBrunoFiles
} = require('../utils/filesystem');
const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
@@ -72,6 +73,7 @@ const collectionWatcher = require('../app/collection-watcher');
const { transformBrunoConfigBeforeSave } = require('../utils/transformBrunoConfig');
const { REQUEST_TYPES } = require('../utils/constants');
const { cancelOAuth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } = require('../utils/oauth2-protocol-handler');
+const { findUniqueFolderName } = require('../utils/collection-import');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
@@ -1106,122 +1108,171 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
});
ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = DEFAULT_COLLECTION_FORMAT) => {
- try {
- let collectionName = sanitizeName(collection.name);
- let collectionPath = path.join(collectionLocation, collectionName);
+ let collections = Array.isArray(collection) ? collection : [collection];
+ let completedImports = 0;
+ let failedImports = 0;
+ let successfulImports = [];
- if (fs.existsSync(collectionPath)) {
- throw new Error(`collection: ${collectionPath} already exists`);
- }
+ for (let coll of collections) {
+ try {
+ // Sending a "started" and "ended" event to renderer to start and stop the spinner.
+ mainWindow.webContents.send('main:collection-import-started', coll.uid);
- const getFilenameWithFormat = (item, format) => {
- if (item?.filename) {
- const ext = path.extname(item.filename);
- if (ext === '.bru' || ext === '.yml') {
- return item.filename.replace(ext, `.${format}`);
- }
- return item.filename;
+ let collectionName = sanitizeName(coll.name);
+ let collectionPath = path.join(collectionLocation, collectionName);
+
+ // Auto-rename if collection already exists
+ if (fs.existsSync(collectionPath)) {
+ const uniqueName = await findUniqueFolderName(coll.name, collectionLocation);
+ collectionName = sanitizeName(uniqueName);
+ collectionPath = path.join(collectionLocation, collectionName);
+ coll.name = uniqueName;
}
- return `${item.name}.${format}`;
- };
- // Recursive function to parse the collection items and create files/folders
- const parseCollectionItems = async (items = [], currentPath) => {
- await Promise.all(items.map(async (item) => {
- if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
- let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format));
- const content = await stringifyRequestViaWorker(item, { format });
- const filePath = path.join(currentPath, sanitizedFilename);
+ const getFilenameWithFormat = (item, format) => {
+ if (item?.filename) {
+ const ext = path.extname(item.filename);
+ if (ext === '.bru' || ext === '.yml') {
+ return item.filename.replace(ext, `.${format}`);
+ }
+ return item.filename;
+ }
+ return `${item.name}.${format}`;
+ };
+
+ // Recursive function to parse the collection items and create files/folders
+ const parseCollectionItems = async (items = [], currentPath) => {
+ await Promise.all(items.map(async (item) => {
+ if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
+ let sanitizedFilename = sanitizeName(getFilenameWithFormat(item, format));
+ const content = await stringifyRequestViaWorker(item, { format });
+ const filePath = path.join(currentPath, sanitizedFilename);
+ safeWriteFileSync(filePath, content);
+ }
+ if (item.type === 'folder') {
+ let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
+ const folderPath = path.join(currentPath, sanitizedFolderName);
+ fs.mkdirSync(folderPath);
+
+ if (item?.root?.meta?.name) {
+ const folderFilePath = path.join(folderPath, `folder.${format}`);
+ item.root.meta.seq = item.seq;
+ const folderContent = await stringifyFolder(item.root, { format });
+ safeWriteFileSync(folderFilePath, folderContent);
+ }
+
+ if (item.items && item.items.length) {
+ await parseCollectionItems(item.items, folderPath);
+ }
+ }
+ // Handle items of type 'js'
+ if (item.type === 'js') {
+ let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
+ const filePath = path.join(currentPath, sanitizedFilename);
+ safeWriteFileSync(filePath, item.fileContent);
+ }
+ }));
+ };
+
+ const parseEnvironments = async (environments = [], collectionPath) => {
+ const envDirPath = path.join(collectionPath, 'environments');
+ if (!fs.existsSync(envDirPath)) {
+ fs.mkdirSync(envDirPath);
+ }
+
+ await Promise.all(environments.map(async (env) => {
+ const content = await stringifyEnvironment(env, { format });
+ let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
+ const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
- }
- if (item.type === 'folder') {
- let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
- const folderPath = path.join(currentPath, sanitizedFolderName);
- fs.mkdirSync(folderPath);
+ }));
+ };
- if (item?.root?.meta?.name) {
- const folderFilePath = path.join(folderPath, `folder.${format}`);
- item.root.meta.seq = item.seq;
- const folderContent = await stringifyFolder(item.root, { format });
- safeWriteFileSync(folderFilePath, folderContent);
- }
+ const getBrunoJsonConfig = (collection) => {
+ let brunoConfig = collection.brunoConfig;
- if (item.items && item.items.length) {
- await parseCollectionItems(item.items, folderPath);
- }
+ if (!brunoConfig) {
+ brunoConfig = {
+ version: '1',
+ name: collection.name,
+ type: 'collection',
+ ignore: ['node_modules', '.git']
+ };
}
- // Handle items of type 'js'
- if (item.type === 'js') {
- let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
- const filePath = path.join(currentPath, sanitizedFilename);
- safeWriteFileSync(filePath, item.fileContent);
- }
- }));
- };
- const parseEnvironments = async (environments = [], collectionPath) => {
- const envDirPath = path.join(collectionPath, 'environments');
- if (!fs.existsSync(envDirPath)) {
- fs.mkdirSync(envDirPath);
+ return brunoConfig;
+ };
+
+ await createDirectory(collectionPath);
+
+ const uid = generateUidBasedOnHash(collectionPath);
+ const brunoConfig = getBrunoJsonConfig(coll);
+
+ if (format === 'yml') {
+ brunoConfig.opencollection = '1.0.0';
+ const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
+ await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
+ } else if (format === 'bru') {
+ const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
+ await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
+
+ const collectionContent = await stringifyCollection(coll.root, brunoConfig, { format });
+ await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
+ } else {
+ throw new Error(`Invalid format: ${format}`);
}
- await Promise.all(environments.map(async (env) => {
- const content = await stringifyEnvironment(env, { format });
- let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
- const filePath = path.join(envDirPath, sanitizedEnvFilename);
- safeWriteFileSync(filePath, content);
- }));
- };
+ // create folder and files based on collection
+ await parseCollectionItems(coll.items, collectionPath);
+ await parseEnvironments(coll.environments, collectionPath);
- const getBrunoJsonConfig = (collection) => {
- let brunoConfig = collection.brunoConfig;
+ const { size, filesCount } = await getCollectionStats(collectionPath);
+ brunoConfig.size = size;
+ brunoConfig.filesCount = filesCount;
- if (!brunoConfig) {
- brunoConfig = {
- version: '1',
- name: collection.name,
- type: 'collection',
- ignore: ['node_modules', '.git']
- };
- }
+ mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
+ ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
- return brunoConfig;
- };
+ mainWindow.webContents.send('main:collection-import-ended', coll.uid);
- await createDirectory(collectionPath);
+ successfulImports.push({
+ path: collectionPath,
+ name: coll.name
+ });
+ // Increment completed imports
+ completedImports++;
+ } catch (error) {
+ mainWindow.webContents.send('main:collection-import-failed', coll.uid, {
+ message: `Error ${error.message}`
+ });
+ console.error(`Failed to import collection: ${coll.name}, Error: ${error.message}`);
- const uid = generateUidBasedOnHash(collectionPath);
- let brunoConfig = getBrunoJsonConfig(collection);
+ // Increment failed imports
+ failedImports++;
- if (format === 'yml') {
- brunoConfig.opencollection = '1.0.0';
- const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
- await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent);
- } else if (format === 'bru') {
- const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
- await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
-
- const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format });
- await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
- } else {
- throw new Error(`Invalid format: ${format}`);
+ // Continue with next collection instead of breaking
+ continue;
}
-
- const { size, filesCount } = await getCollectionStats(collectionPath);
- brunoConfig.size = size;
- brunoConfig.filesCount = filesCount;
-
- mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
- ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
-
- // create folder and files based on collection
- await parseCollectionItems(collection.items, collectionPath);
- await parseEnvironments(collection.environments, collectionPath);
-
- return collectionPath;
- } catch (error) {
- return Promise.reject(error);
}
+
+ // Send final status when all collections have been processed (either succeeded or failed)
+ if ((completedImports + failedImports) === collections.length) {
+ mainWindow.webContents.send('main:all-collections-import-ended', {
+ message: `Import completed. ${completedImports} collections imported successfully, ${failedImports} failed.`,
+ status: {
+ total: collections.length,
+ succeeded: completedImports,
+ failed: failedImports
+ }
+ });
+ }
+
+ return {
+ success: {
+ count: completedImports,
+ items: successfulImports
+ }
+ };
});
ipcMain.handle('renderer:clone-folder', async (event, itemFolder, collectionPath, collectionPathname) => {
@@ -2320,6 +2371,14 @@ const registerMainEventHandlers = (mainWindow, watcher) => {
app.addRecentDocument(pathname);
});
+ ipcMain.handle('renderer:scan-for-bruno-files', (event, dir) => {
+ try {
+ return scanForBrunoFiles(dir);
+ } catch (error) {
+ throw new Error(error.message);
+ }
+ });
+
// The app listen for this event and allows the user to save unsaved requests before closing the app
ipcMain.on('main:start-quit-flow', () => {
mainWindow.webContents.send('main:start-quit-flow');
diff --git a/packages/bruno-electron/src/ipc/git.js b/packages/bruno-electron/src/ipc/git.js
new file mode 100644
index 000000000..08ad81515
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/git.js
@@ -0,0 +1,22 @@
+const { ipcMain } = require('electron');
+const { cloneGitRepository } = require('../utils/git');
+const { createDirectory, removeDirectory } = require('../utils/filesystem');
+
+const registerGitIpc = (mainWindow) => {
+ ipcMain.handle('renderer:clone-git-repository', async (event, { url, path, processUid }) => {
+ let directoryCreated = false;
+ try {
+ await createDirectory(path);
+ directoryCreated = true;
+ await cloneGitRepository(mainWindow, { url, path, processUid });
+ return 'Repository cloned successfully';
+ } catch (error) {
+ if (directoryCreated) {
+ await removeDirectory(path);
+ }
+ return Promise.reject(error);
+ }
+ });
+};
+
+module.exports = registerGitIpc;
diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js
index 07deec517..f9d1b387f 100644
--- a/packages/bruno-electron/src/ipc/preferences.js
+++ b/packages/bruno-electron/src/ipc/preferences.js
@@ -1,5 +1,6 @@
const { ipcMain, nativeTheme } = require('electron');
const { getPreferences, savePreferences } = require('../store/preferences');
+const { getGitVersion } = require('../utils/git');
const { globalEnvironmentsStore } = require('../store/global-environments');
const { getCachedSystemProxy, refreshSystemProxy } = require('../store/system-proxy');
@@ -20,6 +21,9 @@ const registerPreferencesIpc = (mainWindow) => {
console.error(error);
}
+ const gitVersion = await getGitVersion();
+ mainWindow.webContents.send('main:git-version', gitVersion);
+
ipcMain.emit('main:renderer-ready', mainWindow);
});
diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js
index 7e88f0c95..84b758ca9 100644
--- a/packages/bruno-electron/src/utils/filesystem.js
+++ b/packages/bruno-electron/src/utils/filesystem.js
@@ -474,6 +474,33 @@ const isCollectionRootBruFile = (pathname, collectionPath) => {
return dirname === collectionPath && basename === 'collection.bru';
};
+const scanForBrunoFiles = async (dir) => {
+ const brunoFolders = [];
+
+ const scanDir = (currentDir) => {
+ const files = fs.readdirSync(currentDir);
+
+ if (files && files.length) {
+ files.forEach((file) => {
+ const fullPath = path.join(currentDir, file);
+ const stat = fs.statSync(fullPath);
+
+ if (stat.isDirectory()) {
+ if (['node_modules', '.git'].includes(file)) {
+ return;
+ }
+ scanDir(fullPath);
+ } else if (file === 'bruno.json') {
+ brunoFolders.push(currentDir);
+ }
+ });
+ }
+ };
+
+ scanDir(dir);
+ return brunoFolders;
+};
+
module.exports = {
DEFAULT_GITIGNORE,
isValidPathname,
@@ -514,5 +541,6 @@ module.exports = {
isValidDotEnvFilename,
isBrunoConfigFile,
isBruEnvironmentConfig,
- isCollectionRootBruFile
+ isCollectionRootBruFile,
+ scanForBrunoFiles
};
diff --git a/packages/bruno-electron/src/utils/git.js b/packages/bruno-electron/src/utils/git.js
new file mode 100644
index 000000000..6901b7f99
--- /dev/null
+++ b/packages/bruno-electron/src/utils/git.js
@@ -0,0 +1,1814 @@
+const simpleGit = require('simple-git');
+const fs = require('fs');
+const path = require('path');
+const { exec } = require('child_process');
+const { parseRequest } = require('@usebruno/filestore');
+
+let collectionPathToGitRootPathMap = new Map();
+
+const simpleGitInstances = new Map();
+
+const getGitVersion = () => {
+ return new Promise((resolve, reject) => {
+ exec('git --version', (error, stdout, stderr) => {
+ if (error) {
+ return resolve(null);
+ }
+ const gitVersion = stdout.trim();
+ return resolve(gitVersion);
+ });
+ });
+};
+
+const getSimpleGitInstanceForPath = (gitRootPath) => {
+ let git = simpleGitInstances.get(gitRootPath);
+ if (!git) {
+ git = simpleGit(gitRootPath);
+ simpleGitInstances.set(gitRootPath, git);
+ }
+ return git;
+};
+
+const handleGitOutput = ({ win, processUid, sendStdout = false }) => (command, stdout, stderr) => {
+ const sendProgressUpdate = (data) => {
+ win.webContents.send('main:update-git-operation-progress', {
+ uid: processUid,
+ data: data.toString()
+ });
+ };
+
+ stderr.on('data', sendProgressUpdate);
+
+ if (sendStdout) {
+ stdout.on('data', sendProgressUpdate);
+ }
+};
+
+const findGitRootPath = (collectionPath) => {
+ const gitPath = path.join(collectionPath, '.git');
+ try {
+ if (fs.existsSync(gitPath)) {
+ return gitPath?.split('.git')?.[0];
+ } else {
+ const parentDir = path.dirname(collectionPath);
+ if (parentDir === collectionPath) {
+ return null;
+ } else {
+ return findGitRootPath(parentDir);
+ }
+ }
+ } catch (err) {
+ console.error('Error finding .git path:', err);
+ return null;
+ }
+};
+
+const getCollectionGitRootPath = (collectionPath) => {
+ let savedGitRootPath = collectionPathToGitRootPathMap.get(collectionPath);
+ if (savedGitRootPath) {
+ return savedGitRootPath;
+ }
+ let gitRootPath = findGitRootPath(collectionPath);
+ collectionPathToGitRootPathMap.set(collectionPath, gitRootPath);
+ return gitRootPath;
+};
+
+const getCollectionGitRepoUrl = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.listRemote(['--get-url', 'origin'], (err, data) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(data.trim());
+ });
+ });
+};
+
+const initGit = async (gitRootPath) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ await git.init();
+ // Create and checkout main branch -> This is specific for use with Bruno
+ return await git.raw(['branch', '-M', 'main']);
+};
+
+const stageChanges = async (gitRootPath, files) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.add(files, (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+};
+
+const unstageChanges = async (gitRootPath, files) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ // First check the status to see which files are actually staged
+ git.status(['--porcelain'], (err, status) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ // Filter files to only include those that are actually staged
+ const stagedFiles = files.filter((fullPath) => {
+ const relativePath = path.relative(gitRootPath, fullPath);
+ // Normalize path separators for cross-platform compatibility
+ const normalizedPath = relativePath.replace(/\\/g, '/');
+ return status.files.some((file) =>
+ file.path === normalizedPath
+ && (file.index === 'M' || file.index === 'A' || file.index === 'D')
+ );
+ });
+
+ // If no files are actually staged, just resolve
+ if (stagedFiles.length === 0) {
+ resolve();
+ return;
+ }
+
+ // Unstage only the files that are actually staged
+ git.reset(['HEAD', '--', ...stagedFiles], (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+ });
+};
+
+const discardChanges = async (gitRootPath, filePaths) => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ // Get current git status to categorize files
+ git.status(['--porcelain'], async (err, status) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ // Create a map of file paths to their status
+ const fileStatusMap = {};
+ status.files.forEach((file) => {
+ fileStatusMap[file.path] = file;
+ });
+
+ // Categorize files based on their git status
+ const trackedFiles = [];
+ const untrackedFiles = [];
+
+ filePaths.forEach((filePath) => {
+ // Normalize paths for comparison
+ const relativePath = filePath.startsWith(gitRootPath)
+ ? path.relative(gitRootPath, filePath) : filePath;
+
+ // Normalize path separators for cross-platform compatibility
+ const normalizedPath = relativePath.replace(/\\/g, '/');
+ const fileStatus = fileStatusMap[normalizedPath];
+
+ // If the file is untracked, we need to delete it from the filesystem
+ // ? means untracked
+ if (fileStatus && fileStatus.working_dir === '?') {
+ // Untracked file - needs to be deleted from filesystem
+ untrackedFiles.push(filePath);
+ } else if (fileStatus) {
+ // Tracked file - can be discarded with git checkout
+ trackedFiles.push(filePath);
+ } else {
+ // File not in status - might be already deleted, renamed, or doesn't exist
+ console.warn(`File not found in git status: ${relativePath}. File may have been already deleted or moved.`);
+
+ // Check if it's an absolute path that needs to be treated as untracked
+ if (filePath.startsWith(gitRootPath) && fs.existsSync(filePath)) {
+ console.log(`Treating unknown file as untracked: ${relativePath}`);
+ untrackedFiles.push(filePath);
+ }
+ }
+ });
+
+ // Handle tracked and untracked files sequentially
+ try {
+ // Handle tracked files with git checkout
+ if (trackedFiles.length > 0) {
+ await new Promise((checkoutResolve, checkoutReject) => {
+ git.checkout(trackedFiles, (err, res) => {
+ if (err) {
+ console.error('Error discarding tracked files:', err);
+ checkoutReject(err);
+ } else {
+ console.log(`Discarded ${trackedFiles.length} tracked files`);
+ checkoutResolve(res);
+ }
+ });
+ });
+ }
+
+ // Handle untracked files by deleting them from filesystem
+ if (untrackedFiles.length > 0) {
+ for (const filePath of untrackedFiles) {
+ const fullPath = filePath.startsWith(gitRootPath) ? filePath : path.join(gitRootPath, filePath);
+
+ // Check if file exists before trying to delete
+ if (fs.existsSync(fullPath)) {
+ await fs.promises.unlink(fullPath);
+ console.log(`Deleted untracked file: ${fullPath}`);
+ }
+ }
+ }
+
+ resolve({
+ trackedFilesDiscarded: trackedFiles.length,
+ untrackedFilesDeleted: untrackedFiles.length
+ });
+ } catch (discardError) {
+ console.error('Error during discard operation:', discardError);
+ reject(discardError);
+ }
+ });
+ } catch (gitStatusError) {
+ reject(gitStatusError);
+ }
+ });
+};
+
+const commitChanges = async (gitRootPath, message) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.commit(message, (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+};
+
+const getStagedFileDiff = async (gitRootPath, filePath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.diff(['--no-prefix', '--staged', '--', filePath], (err, stagedChanges) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(stagedChanges);
+ });
+ });
+};
+
+const getRenamedFileDiff = async (gitRootPath, file) => {
+ return new Promise((resolve, reject) => {
+ const git = simpleGit(gitRootPath);
+ git.diff(['--staged', '--', file.from, file.to], (err, stagedChanges) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(stagedChanges);
+ });
+ });
+};
+
+const getUnstagedFileDiff = async (gitRootPath, filePath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ git.status((err, status) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const isFileTracked = status.files.some((file) => {
+ const statusFilePath = path.join(gitRootPath, file.path);
+ return filePath === statusFilePath && file.index !== '?' && file.working_dir !== '?';
+ });
+
+ if (isFileTracked) {
+ git.diff(['--no-prefix', '--diff-filter=ACMD', '--', filePath], (err, tracked) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(tracked);
+ });
+ } else {
+ fs.readFile(filePath, 'utf8', (err, content) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const prefixedLines = content
+ .split('\n')
+ .map((line) => `+${line}`);
+ const lineCount = prefixedLines.length;
+ const lines = prefixedLines.join('\n');
+
+ let diff
+ = [
+ `diff --git a/${filePath} b/${filePath}`,
+ `new file mode 100644`,
+ `--- a/${filePath}`,
+ `+++ b/${filePath}`,
+ `@@ -0,0 +1,${lineCount} @@`,
+ `${lines}`
+ ].join('\n') + '\n';
+
+ resolve(diff);
+ });
+ }
+ });
+ });
+};
+
+const getCollectionGitBranches = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.branchLocal((err, branches) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(branches.all);
+ });
+ });
+};
+
+const getCurrentGitBranch = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.branchLocal((err, branches) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(branches.current);
+ });
+ });
+};
+
+const getDefaultGitBranch = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.raw(['symbolic-ref', '--short', 'HEAD'], (err, branch) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(branch.trim());
+ });
+ });
+};
+
+const checkoutGitBranch = async (win, { gitRootPath, branchName, processUid, shouldCreate = false }) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.outputHandler(handleGitOutput({ win, processUid }));
+
+ const checkoutArgs = shouldCreate ? ['-b', branchName, '--progress'] : branchName;
+ git.checkout(checkoutArgs, (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+};
+
+const checkoutRemoteGitBranch = async (win, { gitRootPath, remoteName, branchName, processUid }) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.outputHandler(handleGitOutput({ win, processUid }));
+
+ const remoteBranchName = `${remoteName}/${branchName}`;
+
+ // Check if the remote branch exists
+ git.branch(['-r'], async (err, branches) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const remoteBranches = branches.all.map((branch) => branch.trim());
+ const remoteBranchExists = remoteBranches.includes(remoteBranchName);
+
+ if (remoteBranchExists) {
+ try {
+ const localBranches = await getCollectionGitBranches(gitRootPath);
+ const localBranchExists = localBranches.includes(branchName);
+ if (localBranchExists) {
+ // Set the local branch to track the remote branch
+ git.branch(['--set-upstream-to', remoteBranchName, branchName], async (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ git.checkout(branchName, (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ });
+ }
+ });
+ } else {
+ git.checkout(['-b', branchName, '--track', remoteBranchName, '--progress'], (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ });
+ }
+ } catch (err) {
+ reject(err);
+ }
+ } else {
+ reject(new Error(`Remote branch ${remoteBranchName} does not exist`));
+ }
+ });
+ });
+};
+
+const getCollectionGitLogs = async (gitRootPath) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ try {
+ // Get logs with shortstat for file change info
+ const result = await git.raw([
+ 'log',
+ '--format=%H|%s|%an|%aI',
+ '--shortstat',
+ '-n', '500'
+ ]);
+
+ if (!result || !result.trim()) {
+ return [];
+ }
+
+ const commits = [];
+ const lines = result.split('\n');
+ let currentCommit = null;
+
+ for (const line of lines) {
+ const trimmedLine = line.trim();
+ if (!trimmedLine) continue;
+
+ // Check if this is a commit line (contains | separators)
+ if (trimmedLine.includes('|')) {
+ // If we have a pending commit, push it
+ if (currentCommit) {
+ commits.push(currentCommit);
+ }
+
+ const parts = trimmedLine.split('|');
+ if (parts.length >= 4) {
+ currentCommit = {
+ hash: parts[0],
+ message: parts[1],
+ author_name: parts[2],
+ date: parts[3],
+ filesChanged: 0,
+ insertions: 0,
+ deletions: 0
+ };
+ }
+ } else if (currentCommit && trimmedLine.includes('changed')) {
+ // This is a shortstat line, parse it
+ // Format: " 3 files changed, 45 insertions(+), 12 deletions(-)"
+ const filesMatch = trimmedLine.match(/(\d+) files? changed/);
+ const insertionsMatch = trimmedLine.match(/(\d+) insertions?\(\+\)/);
+ const deletionsMatch = trimmedLine.match(/(\d+) deletions?\(-\)/);
+
+ if (filesMatch) currentCommit.filesChanged = parseInt(filesMatch[1], 10);
+ if (insertionsMatch) currentCommit.insertions = parseInt(insertionsMatch[1], 10);
+ if (deletionsMatch) currentCommit.deletions = parseInt(deletionsMatch[1], 10);
+ }
+ }
+
+ // Push the last commit if exists
+ if (currentCommit) {
+ commits.push(currentCommit);
+ }
+
+ return commits;
+ } catch (err) {
+ console.error('Error getting git logs:', err);
+ return [];
+ }
+};
+
+const getCollectionGitTagsWithDetails = (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git
+ .tags(['-l', '--format=%(refname:short)||%(creatordate)||%(creator)'])
+ .then((tags) => {
+ const tagDetails = tags.all?.map((tag) => {
+ const [name, date, author] = tag.split('||');
+ return { name, date, author };
+ });
+ resolve(tagDetails);
+ })
+ .catch(reject);
+ });
+};
+
+const canPush = async (gitRootPath) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']);
+ const remote = await git.listRemote(['--get-url', 'origin']);
+
+ if (!remote) {
+ throw new Error('Remote not configured');
+ }
+
+ const remoteInfo = await git.lsRemote(['--refs', remote]);
+ const logs = await git.log({ maxCount: 1 });
+ const localHead = logs.latest.hash;
+ const remoteRefs = remoteInfo.split('\n');
+ const remoteHeads = remoteRefs.reduce((acc, ref) => {
+ const [hash, refName] = ref.split('\t');
+ acc[refName.replace('refs/heads/', '')] = hash;
+ return acc;
+ }, {});
+ const remoteHead = remoteHeads[branch];
+
+ if (localHead === remoteHead) {
+ return false;
+ }
+
+ return true;
+};
+
+const pushGitChanges = async (win, { gitRootPath, processUid, remote, remoteBranch }) => {
+ return new Promise(async (resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.outputHandler(handleGitOutput({ win, processUid, sendStdout: true }));
+
+ try {
+ // Check if the local branch is tracking a remote branch
+ git.branch((err, branchSummary) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const currentBranch = branchSummary.branches[remoteBranch];
+
+ if (!currentBranch) {
+ reject(new Error(`Branch ${remoteBranch} does not exist.`));
+ return;
+ }
+
+ const trackingBranch = currentBranch.tracking;
+
+ if (!trackingBranch) {
+ // Set the upstream tracking branch
+ git.push(['--set-upstream', remote, remoteBranch], (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ });
+ } else {
+ // Push the local branch to the remote
+ git.push(remote, remoteBranch, (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ });
+ }
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+};
+
+const pullGitChanges = async (win, data) => {
+ const { gitRootPath, processUid, remote, remoteBranch, strategy } = data;
+ if (strategy !== '--no-rebase' && strategy !== '--ff-only') {
+ throw new Error('Invalid strategy');
+ }
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.outputHandler(handleGitOutput({ win, processUid, sendStdout: true })).pull(remote, remoteBranch, [strategy], (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ });
+ });
+};
+
+async function getChangedFilesInCollectionGit(_gitRootPath, _collectionPath) {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(_gitRootPath);
+ git.status(['--porcelain', _gitRootPath], async (err, status) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const totalFiles = status?.files?.length || 0;
+ if (totalFiles > 5000) {
+ return resolve({
+ staged: [],
+ unstaged: [],
+ totalFiles,
+ tooManyFiles: true
+ });
+ }
+
+ const unstaged = await Promise.all(
+ status.files
+ .filter(
+ (file) => file.index === '?' || file.index === ' ' || file.working_dir === '?' || file.working_dir === 'M'
+ )
+ .map(async (file) => {
+ return { path: file.path, type: 'unstaged', fileIndex: file.index, working_dir: file.working_dir };
+ })
+ );
+
+ const renamed = await Promise.all(
+ status.renamed.map(async (file) => {
+ return { path: file.to, to: file.to, from: file.from, type: 'renamed', fileIndex: 'R', working_dir: '' };
+ })
+ );
+
+ const staged = await Promise.all(
+ status.files
+ .filter(
+ (file) =>
+ (file.index === 'M' || file.index === 'A' || file.index === 'D')
+ && (file.working_dir === 'M' || file.working_dir === ' ')
+ )
+ .map(async (file) => {
+ return { path: file.path, type: 'staged', fileIndex: file.index, working_dir: file.working_dir };
+ })
+ );
+
+ const conflicted = await Promise.all(
+ status.files.filter((file) => file.index === 'U' || file.working_dir === 'U').map(async (file) => {
+ return { path: file.path, type: 'conflicted', fileIndex: file.index, working_dir: file.working_dir };
+ }) || []
+ );
+
+ resolve({
+ staged: [...staged, ...renamed],
+ unstaged,
+ totalFiles,
+ tooManyFiles: false,
+ conflicted
+ });
+ });
+ });
+}
+
+const getCollectionGitData = async (gitRootPath, collectionPath) => {
+ if (!gitRootPath) return {};
+ const [branches, currentGitBranch, defaultGitBranch, gitRepoUrl] = await Promise.all([
+ getCollectionGitBranches(gitRootPath),
+ getCurrentGitBranch(gitRootPath),
+ getDefaultGitBranch(gitRootPath),
+ getCollectionGitRepoUrl(gitRootPath)
+ ]);
+
+ const logs = branches.length ? await getCollectionGitLogs(gitRootPath) : [];
+
+ return {
+ gitRootPath,
+ gitRepoUrl,
+ branches,
+ currentGitBranch,
+ defaultGitBranch,
+ logs
+ };
+};
+
+const cloneGitRepository = async (win, data) => {
+ return new Promise((resolve, reject) => {
+ const { url, path, processUid } = data;
+ const git = getSimpleGitInstanceForPath(path);
+
+ git.outputHandler(handleGitOutput({ win, processUid, sendStdout: true }));
+ git.clone(url, path, ['--progress'], (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+};
+
+const fetchRemotes = (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ if (!gitRootPath) return resolve([]);
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.getRemotes(true)
+ .then((remoteList) => {
+ resolve(remoteList);
+ })
+ .catch((err) => {
+ console.error('Error fetching remotes:', err);
+ reject(err);
+ });
+ });
+};
+
+const fetchChanges = (gitRootPath, remote = 'origin') => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.fetch(remote, (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+};
+
+const fetchRemoteBranches = ({ gitRootPath, remote }) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ git.branch(['-r'], (err, branches) => {
+ if (err) {
+ reject(err);
+ } else {
+ const branchNames = branches?.all
+ .filter((branch) => branch.startsWith(`${remote}/`))
+ .map((branch) => branch.slice(remote.length + 1));
+ resolve(branchNames);
+ }
+ });
+ });
+};
+
+const addRemote = ({ gitRootPath, remoteName, remoteUrl }) => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ console.log('Adding remote:', { gitRootPath, remoteName, remoteUrl });
+ await git.addRemote(remoteName, remoteUrl);
+ const remotes = await fetchRemotes(gitRootPath);
+ resolve(remotes);
+ } catch (err) {
+ console.error('Error adding remote:', err);
+ reject(err);
+ }
+ });
+};
+
+const removeRemote = ({ gitRootPath, remoteName }) => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ console.log('Removing remote:', { gitRootPath, remoteName });
+ await git.removeRemote(remoteName);
+ const remotes = await fetchRemotes(gitRootPath);
+ resolve(remotes);
+ } catch (err) {
+ console.error('Error removing remote:', err);
+ reject(err);
+ }
+ });
+};
+
+const getBehindCount = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ // First try to get status which includes tracking info and counts
+ git.status((err, status) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ // Check if we have tracking branch information
+ const trackingBranch = status.tracking;
+ if (!trackingBranch) {
+ // No tracking branch set
+ resolve({
+ behind: 0,
+ commits: []
+ });
+ return;
+ }
+
+ // Use status.behind if available, otherwise calculate manually
+ const behindCount = status.behind || 0;
+
+ if (behindCount === 0) {
+ resolve({
+ behind: 0,
+ commits: []
+ });
+ return;
+ }
+
+ // Get the actual commits that are behind
+ git.log(['HEAD..' + trackingBranch], (err, log) => {
+ if (err) {
+ // If log fails, return the count from status but empty commits
+ resolve({
+ behind: behindCount,
+ commits: []
+ });
+ return;
+ }
+
+ const commits = log.all.map((commit) => ({
+ hash: commit.hash,
+ message: commit.message,
+ author: commit.author_name,
+ time: new Date(commit.date).toLocaleString()
+ }));
+
+ resolve({
+ behind: behindCount,
+ commits
+ });
+ });
+ });
+ });
+};
+
+const getAheadCount = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ // First try to get status which includes tracking info and counts
+ git.status((err, status) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ // Check if we have tracking branch information
+ const trackingBranch = status.tracking;
+ if (!trackingBranch) {
+ // No tracking branch set - get all local commits as "ahead"
+ git.log(['HEAD'], (err, allLog) => {
+ if (err) {
+ resolve({
+ ahead: 0,
+ commits: []
+ });
+ return;
+ }
+
+ const commits = allLog.all.map((commit) => ({
+ hash: commit.hash,
+ message: commit.message,
+ author: commit.author_name,
+ time: new Date(commit.date).toLocaleString()
+ }));
+
+ resolve({
+ ahead: commits.length,
+ commits
+ });
+ });
+ return;
+ }
+
+ // Use status.ahead if available, otherwise calculate manually
+ const aheadCount = status.ahead || 0;
+
+ if (aheadCount === 0) {
+ resolve({
+ ahead: 0,
+ commits: []
+ });
+ return;
+ }
+
+ // Get commits that are ahead (in local but not on remote)
+ git.log([trackingBranch + '..HEAD'], (err, log) => {
+ if (err) {
+ // If remote doesn't exist, get all local commits (they're all "ahead")
+ git.log(['HEAD'], (err, allLog) => {
+ if (err) {
+ resolve({
+ ahead: aheadCount,
+ commits: []
+ });
+ return;
+ }
+
+ const commits = allLog.all.map((commit) => ({
+ hash: commit.hash,
+ message: commit.message,
+ author: commit.author_name,
+ time: new Date(commit.date).toLocaleString()
+ }));
+
+ resolve({
+ ahead: aheadCount,
+ commits
+ });
+ });
+ return;
+ }
+
+ const commits = log.all.map((commit) => ({
+ hash: commit.hash,
+ message: commit.message,
+ author: commit.author_name,
+ time: new Date(commit.date).toLocaleString()
+ }));
+
+ resolve({
+ ahead: aheadCount,
+ commits
+ });
+ });
+ });
+ });
+};
+
+const getAheadBehindCount = async (gitRootPath) => {
+ try {
+ const [behindStatus, aheadStatus] = await Promise.all([
+ getBehindCount(gitRootPath),
+ getAheadCount(gitRootPath)
+ ]);
+
+ return {
+ behind: behindStatus.behind,
+ ahead: aheadStatus.ahead,
+ behindCommits: behindStatus.commits,
+ aheadCommits: aheadStatus.commits
+ };
+ } catch (error) {
+ console.error('Error getting ahead/behind count:', error);
+ // Return safe defaults
+ return {
+ behind: 0,
+ ahead: 0,
+ behindCommits: [],
+ aheadCommits: []
+ };
+ }
+};
+
+const abortConflictResolution = async (gitRootPath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ if (fs.existsSync(path.join(gitRootPath, '.git', 'MERGE_HEAD'))) {
+ git.raw(['merge', '--abort'], (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ });
+ } else {
+ reject(new Error('No merge in progress'));
+ }
+ });
+};
+
+const continueMerge = async (gitRootPath, conflictedFiles, commitMessage) => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const fsPromises = require('fs/promises');
+
+ // Step 1: Write all conflicted files' final state to disk
+ for (const file of conflictedFiles) {
+ // file.path is relative to gitRootPath, convert to absolute path
+ const fullFilePath = path.join(gitRootPath, file.path);
+
+ // Ensure directory exists
+ const dir = path.dirname(fullFilePath);
+ await fsPromises.mkdir(dir, { recursive: true });
+
+ // Write the resolved content
+ await fsPromises.writeFile(fullFilePath, file.content, 'utf8');
+ }
+
+ // Step 2: Stage the conflicted files
+ const filePaths = conflictedFiles.map((f) => f.path);
+ const fullPaths = filePaths.map((p) => path.join(gitRootPath, p));
+ await stageChanges(gitRootPath, fullPaths);
+
+ // Step 3: Write commit message to .git/MERGE_MSG
+ const mergeMsgPath = path.join(gitRootPath, '.git', 'MERGE_MSG');
+ await fsPromises.writeFile(mergeMsgPath, commitMessage, 'utf8');
+
+ // Step 4: Call git merge --continue
+ exec('git -c core.editor=: merge --continue', { cwd: gitRootPath }, (err, stdout) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(stdout);
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+};
+
+const getCommitFiles = async (gitRootPath, commitHash) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ // Get the list of files changed in this commit with stats
+ git.raw(['show', '--stat', '--name-status', '--format=', commitHash], (err, result) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const lines = result.trim().split('\n').filter((line) => line.trim());
+ const files = [];
+
+ for (const line of lines) {
+ // Parse name-status format: M
filename or Afilename or Dfilename
+ const match = line.match(/^([AMDRC])\t(.+)$/);
+ if (match) {
+ const [, status, filePath] = match;
+ files.push({
+ path: filePath,
+ status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'M' ? 'modified' : status === 'R' ? 'renamed' : 'changed'
+ });
+ }
+ }
+
+ resolve(files);
+ });
+ });
+};
+
+const getCommitFileDiff = async (gitRootPath, commitHash, filePath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ // Get the diff for a specific file in a commit (compare with parent)
+ git.raw(['show', '--no-prefix', '-p', commitHash, '--', filePath], (err, diff) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(diff);
+ });
+ });
+};
+
+/**
+ * Get the list of files changed between two commits
+ * @param {string} gitRootPath - Path to git repository
+ * @param {string} fromCommit - Base commit hash (older)
+ * @param {string} toCommit - Target commit hash (newer)
+ * @returns {Promise} List of changed files with status
+ */
+const getCommitCompareFiles = async (gitRootPath, fromCommit, toCommit) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ // Get the list of files changed between two commits
+ git.raw(['diff', '--name-status', fromCommit, toCommit], (err, result) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ const lines = result.trim().split('\n').filter((line) => line.trim());
+ const files = [];
+
+ for (const line of lines) {
+ // Parse name-status format: Mfilename or Afilename or Dfilename
+ const match = line.match(/^([AMDRC])\t(.+)$/);
+ if (match) {
+ const [, status, filePath] = match;
+ files.push({
+ path: filePath,
+ status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : status === 'M' ? 'modified' : status === 'R' ? 'renamed' : 'changed'
+ });
+ }
+ }
+
+ resolve(files);
+ });
+ });
+};
+
+/**
+ * Get the diff for a specific file between two commits
+ * @param {string} gitRootPath - Path to git repository
+ * @param {string} fromCommit - Base commit hash (older)
+ * @param {string} toCommit - Target commit hash (newer)
+ * @param {string} filePath - Path to the file
+ * @returns {Promise} Diff string
+ */
+const getCommitCompareFileDiff = async (gitRootPath, fromCommit, toCommit, filePath) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ // Get the diff for a specific file between two commits
+ git.raw(['diff', '--no-prefix', fromCommit, toCommit, '--', filePath], (err, diff) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(diff);
+ });
+ });
+};
+
+/**
+ * Get git history for a specific file
+ * @param {string} gitRootPath - Path to git repository
+ * @param {string} filePath - Path to the file (relative to git root)
+ * @returns {Promise} List of commits that touched this file
+ */
+const getFileGitHistory = async (gitRootPath, filePath) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+
+ try {
+ const result = await git.raw([
+ 'log',
+ '--format=%H|%s|%an|%aI',
+ '--follow',
+ '-n', '100',
+ '--', filePath
+ ]);
+
+ if (!result || !result.trim()) {
+ return [];
+ }
+
+ const commits = [];
+ const lines = result.trim().split('\n');
+
+ for (const line of lines) {
+ if (line.includes('|')) {
+ const [hash, message, author, date] = line.split('|');
+ commits.push({
+ hash,
+ message,
+ author_name: author,
+ date
+ });
+ }
+ }
+
+ return commits;
+ } catch (err) {
+ console.error('Error getting file git history:', err);
+ return [];
+ }
+};
+
+/**
+ * Get git graph data for visualization
+ * Gets commits from branch with parent info in a single git log call
+ * Only includes branch commits that fall within the time range of the main line
+ */
+/**
+ * Create a new stash with a message
+ * @param {string} gitRootPath - Path to git repository
+ * @param {string} message - Stash message/identifier
+ * @returns {Promise}
+ */
+const createStash = async (gitRootPath, message) => {
+ return new Promise((resolve, reject) => {
+ const git = getSimpleGitInstanceForPath(gitRootPath);
+ // Use --include-untracked to stash untracked files as well
+ git.stash(['push', '--include-untracked', '-m', message], (err, result) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve(result);
+ });
+ });
+};
+
+/**
+ * Get stash diff stats (files changed, insertions, deletions)
+ * Includes both tracked and untracked files
+ * @param {object} git - simple-git instance
+ * @param {number} stashIndex - Index of the stash
+ * @returns {Promise