From e5935ab05e1202d5b2f428c78c05133d921a83e5 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Tue, 21 Nov 2023 10:56:36 +0100 Subject: [PATCH 1/5] Create Git composable --- src/composables/Git.js | 382 +++++++++++++++++++++++++++++ tests/unit/composables/Git.spec.js | 315 ++++++++++++++++++++++++ 2 files changed, 697 insertions(+) create mode 100644 src/composables/Git.js create mode 100644 tests/unit/composables/Git.spec.js diff --git a/src/composables/Git.js b/src/composables/Git.js new file mode 100644 index 000000000..3c0894214 --- /dev/null +++ b/src/composables/Git.js @@ -0,0 +1,382 @@ +import git from 'isomorphic-git'; +import http from 'isomorphic-git/http/web'; +import * as BrowserFS from 'browserfs'; +import Branch from 'src/models/git/Branch'; +import FileStatus from 'src/models/git/FileStatus'; +import { saveProject } from 'src/composables/Project'; + +const fs = BrowserFS.BFSRequire('fs'); + +/** + * Initialize a new repository. + * @param {string} projectId - Id of project. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitInit(projectId) { + return git.init({ fs, dir: `/${projectId}` }); +} + +/** + * Clone and save project from git in local storage. + * @param {Project} project - Project to save. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function importProject(project) { + return git.clone({ + fs, + http, + url: project.git.repository, + dir: `/${project.id}`, + onAuth: () => ({ + username: project.git.username, + password: project.git.token, + }), + corsProxy: '/cors-proxy', + singleBranch: true, + depth: 1, + }).then(() => saveProject(project)); +} + +/** + * Fetch project on git. + * Warning: It seems that `git.fetch` can throw unexpected error. + * @param {Project} project - Project to update. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitFetch(project) { + if (project.git?.repository) { + return git.fetch({ + fs, + http, + url: project.git.repository, + dir: `/${project.id}`, + onAuth: () => ({ + username: project.git.username, + password: project.git.token, + }), + corsProxy: '/cors-proxy', + }); + } + return Promise.resolve(); +} + +/** + * Get current branch of git project. + * @param {string} projectId - Id of project. + * @returns {Promise} Promise with current branch name on success otherwise error. + */ +export async function getCurrentBranch(projectId) { + return git.currentBranch({ + fs, + dir: `/${projectId}`, + fullname: false, + }); +} + +/** + * Get all branches of project. + * @param {string} projectId - Id of project. + * @returns {Promise} Promise with array of branches on success otherwise error. + */ +export async function getBranches(projectId) { + const dir = `/${projectId}`; + + const [local, remote] = await Promise.all([ + git.listBranches({ + fs, + dir, + }), + git.listBranches({ + fs, + dir, + remote: 'origin', + }), + ]); + + const branches = local.map((localBranch) => (new Branch({ + name: localBranch, + onLocal: true, + onRemote: remote.includes(localBranch), + remote: 'origin', + }))); + + // TODO: remove this when https://github.com/isomorphic-git/isomorphic-git/issues/1650 is resolve. + if (branches.length === 0) { + const currentBranch = await getCurrentBranch(projectId); + branches.push(new Branch({ + name: currentBranch, + onLocal: true, + onRemote: false, + remote: 'origin', + })); + } + + return branches.concat( + remote + .filter((remoteBranch) => !local.includes(remoteBranch)) + .map((remoteBranch) => (new Branch({ + name: remoteBranch, + onLocal: false, + onRemote: true, + remote: 'origin', + }))), + ).filter(({ name }) => name !== 'HEAD'); +} + +/** + * Get list of all the files in the current staging area. + * @param {string} projectId - Id of the project. + * @returns {Promise} Promise with array of filepaths on success otherwise an error. + */ +export async function gitListFiles(projectId) { + return git.listFiles({ + fs, + dir: `/${projectId}`, + }); +} + +/** + * Update remote origin, fetch and checkout the default branch. + * @param {Project} project - Project to update. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitAddRemote(project) { + await git.addRemote({ + fs, + dir: `/${project.id}`, + url: project.git.repository, + remote: 'origin', + force: true, + }); +} + +/** + * Checkout branch. + * @param {string} projectId - Id of project. + * @param {string} branch - Branch name to checkout. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitCheckout(projectId, branch) { + await git.checkout({ + fs, + dir: `/${projectId}`, + ref: branch, + }).catch(({ name }) => Promise.reject({ name })); +} + +/** + * Create branch from another branch. + * @param {string} projectId - Id of project. + * @param {string} newBranchName - New branch name. + * @param {string} branchName - Branch name. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function createBranchFrom(projectId, newBranchName, branchName) { + await git.branch({ + fs, + dir: `/${projectId}`, + ref: newBranchName, + object: branchName, + }).catch(({ name, message }) => { + if (message.indexOf('ENOTDIR: File is not a directory.') >= 0) { + return Promise.reject({ name: 'cannotLockRef', message }); + } + return Promise.reject({ name, message }); + }); +} + +/** + * Add untracked, unstaged or modified files. + * @param {string} projectId - Id of project. + * @param {string} filepath - Path of the file to add. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitAdd(projectId, filepath) { + return git.add({ + fs, + dir: `/${projectId}`, + filepath, + }); +} + +/** + * Add (stage) deleted files. + * @param {string} projectId - Id of project. + * @param {string} filepath - Path of the file to add. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitRemove(projectId, filepath) { + return git.remove({ + fs, + dir: `/${projectId}`, + filepath, + }); +} + +/** + * Update selected branch with git pull. + * @param {Project} project - Project to update. + * @param {string} branchName - Branch name. + * @param {boolean} fastForward - State of fast forward option. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitUpdate(project, branchName, fastForward) { + await git.pull({ + fs, + http, + dir: `/${project.id}`, + ref: branchName, + fastForward, + singleBranch: true, + onAuth: () => ({ + username: project.git.username, + password: project.git.token, + }), + // TODO: Change when we have user information. + author: { + name: 'LetoModelizer', + email: 'LetoModelizer@no-reply.com', + }, + corsProxy: '/cors-proxy', + }); +} + +/** + * Get the status of all files. If filePaths is defined, get the status of the files + * that strictly or partially match the given filePaths. + * @param {string} projectId - Id of project. + * @param {string[]} filepaths - Limit the query to the given files and directories. + * @param {Function} filter - Filter to only return results whose filepath matches a given function. + * @returns {Promise} All files status. + */ +export async function getStatus(projectId, filepaths, filter) { + return git.statusMatrix({ + fs, + dir: `/${projectId}`, + filepaths, + filter, + }).then((files) => files + .map((file) => new FileStatus({ + path: file[0], + headStatus: file[1], + workdirStatus: file[2], + stageStatus: file[3], + }))); +} + +/** + * Push selected branch on server. + * @param {Project} project - Project. + * @param {string} branchName - Branch name. + * @param {boolean} force - State of force option. + * @returns {Promise} Promise with nothing on success otherwise an error. + */ +export async function gitPush(project, branchName, force) { + await git.push({ + fs, + http, + dir: `/${project.id}`, + remote: 'origin', + ref: branchName, + force, + onAuth: () => ({ + username: project.git.username, + password: project.git.token, + }), + corsProxy: '/cors-proxy', + }); +} + +/** + * Commit all staged files. + * @param {string} projectId - Id of project. + * @param {string} message - Commit message. + * @param {boolean} noUpdateBranchValue - If true, does not update the branch pointer + * after creating the commit. + * @returns {Promise} Promise with the SHA-1 object id of the newly created commit on success + * otherwise an error. + */ +export async function gitCommit(projectId, message, noUpdateBranchValue = false) { + return git.commit({ + fs, + dir: `/${projectId}`, + noUpdateBranch: noUpdateBranchValue, + // TODO: Change when we have user information. + author: { + name: 'LetoModelizer', + email: 'LetoModelizer@no-reply.com', + }, + message, + }); +} + +/** + * Add and commit all modifications on the new branch and push it. + * @param {object} project - Object containing all information about the project. + */ +export async function gitGlobalUpload(project) { + const nowDate = new Date(); + const newBranch = `leto-modelizer_${nowDate.getTime()}`; + const files = (await getStatus(project.id)); + const modifiedFiles = files + .filter((file) => file.hasUnstagedChanged + || file.isUntracked + || file.isUnstaged + || file.isStaged) + .map((file) => file.path); + + await gitAdd(project.id, modifiedFiles); + + /* Special case for deleted files. + Unlike usual git, we CAN NOT add (git add) a deleted file, + so here, the idea is to use the remove method of isomorphic-git to stage the + deleted files. + cf: https://github.com/isomorphic-git/isomorphic-git/issues/1099 + */ + const deletedfiles = files.filter((file) => file.isDeleted).map((file) => file.path); + + await gitRemove(project.id, deletedfiles); + + /* + Commiting BEFORE creating a new branch. + Again, due to some specific behavior in isomorphic-git, the modifications are not + transfered (unlike usual git) while creating a new branch or doing a checkout. + + Creating the (orphan) commit before, give the possibility to create a new branch from that commit. + Which will contains all modifications AND will be attached to the newly created branch. + */ + const sha1 = await gitCommit( + project.id, + `leto-modelizer ${nowDate.toDateString()}`, + true, + ); + + await createBranchFrom( + project.id, + newBranch, + sha1, + ); + await gitCheckout(project.id, newBranch); + await gitPush( + project, + newBranch, + true, + ); +} + +/** + * Get all logs from git log. + * @param {string} projectId - Id of project. + * @param {string} ref - The commit to begin walking backwards through the history from. + * @param {number} [depth] - Number of log to retrieve. + * @returns {Promise} Promise with logs on success otherwise an error. + * @see https://isomorphic-git.org/docs/en/log + */ +export async function gitLog(projectId, ref, depth = 25) { + return git.log({ + fs, + dir: `/${projectId}`, + depth, + ref, + }); +} diff --git a/tests/unit/composables/Git.spec.js b/tests/unit/composables/Git.spec.js new file mode 100644 index 000000000..dc1c75113 --- /dev/null +++ b/tests/unit/composables/Git.spec.js @@ -0,0 +1,315 @@ +import { + gitInit, + gitAddRemote, + getCurrentBranch, + gitFetch, + getBranches, + gitCheckout, + createBranchFrom, + gitUpdate, + getStatus, + gitListFiles, + gitPush, + gitAdd, + gitRemove, + gitCommit, + gitLog, + gitGlobalUpload, + importProject, +} from 'src/composables/Git'; +import Branch from 'src/models/git/Branch'; +import git from 'isomorphic-git'; +import FileStatus from 'src/models/git/FileStatus'; + +jest.mock('isomorphic-git', () => ({ + init: jest.fn(() => Promise.resolve('init')), + clone: jest.fn(({ onAuth }) => { + onAuth(); + + return Promise.resolve('clone'); + }), + branch: jest.fn(({ ref }) => { + if (ref === 'error') { + return Promise.reject({ message: 'ERROR' }); + } + + if (ref === 'enotdir') { + return Promise.reject({ message: 'ENOTDIR: File is not a directory.' }); + } + + return Promise.resolve('branch'); + }), + addRemote: jest.fn(() => Promise.resolve('addRemote')), + fetch: jest.fn(), + checkout: jest.fn(() => Promise.resolve('checkout')), + listFiles: jest.fn(() => Promise.resolve(['/test/file.txt'])), + listBranches: jest.fn(({ remote }) => { + if (!remote) { + return Promise.resolve(['HEAD', 'main', 'local']); + } + + return Promise.resolve(['HEAD', 'main', 'remote']); + }), + currentBranch: jest.fn(() => Promise.resolve('main')), + pull: jest.fn(({ onAuth }) => { + onAuth(); + + return Promise.resolve('pull'); + }), + statusMatrix: jest.fn(() => Promise.resolve([['test', 0, 1, 2]])), + push: jest.fn(({ onAuth }) => { + onAuth(); + + return Promise.resolve('pull'); + }), + add: jest.fn(() => Promise.resolve('add')), + remove: jest.fn(() => Promise.resolve('remove')), + commit: jest.fn(() => Promise.resolve('SHA-1')), + log: jest.fn(() => Promise.resolve(['log'])), +})); + +jest.mock('src/composables/Project', () => ({ + saveProject: () => Promise.resolve(), +})); + +describe('Test composable: Git', () => { + let gitFetchMock; + + beforeEach(() => { + localStorage.clear(); + gitFetchMock = jest.fn(({ onAuth }) => { + onAuth(); + + return Promise.resolve('fetch'); + }); + + git.fetch.mockImplementation(gitFetchMock); + }); + + describe('Test function: gitInit', () => { + it('should call git init', async () => { + await gitInit('foo'); + expect(git.init).toBeCalled(); + }); + }); + + describe('Test function: importProject', () => { + it('should call git clone', async () => { + await importProject({ + id: 'foo', + git: { + repository: 'test', + username: 'test', + token: 'test', + }, + }); + expect(git.clone).toBeCalled(); + }); + }); + + describe('Test function: gitAddRemote', () => { + it('should call all needed git method', async () => { + await gitAddRemote({ + id: 'test', + git: { + repository: 'test', + username: 'test', + token: 'test', + }, + }); + + expect(git.addRemote).toBeCalled(); + }); + }); + + describe('Test function: gitFetch', () => { + it('should not call git.fetch', async () => { + const result = await gitFetch({}); + + expect(gitFetchMock).not.toBeCalled(); + expect(result).toBeUndefined(); + }); + + it('should call git.fetch', async () => { + const result = await gitFetch({ git: { repository: 'test' } }); + + expect(gitFetchMock).toBeCalled(); + expect(result).toEqual('fetch'); + }); + }); + + describe('Test function: gitCheckout', () => { + it('should emit checkout event', async () => { + const result = await gitCheckout('projectId', 'test').then(() => 'success'); + + expect(result).toEqual('success'); + }); + }); + + describe('Test function: getCurrentBranch', () => { + it('should return current branch name', async () => { + const result = await getCurrentBranch('test'); + + expect(result).toEqual('main'); + }); + }); + + describe('Test function: getBranches', () => { + it('should return valid branches', async () => { + const branches = await getBranches('test'); + + expect(branches).toEqual([ + new Branch({ + name: 'main', + onLocal: true, + onRemote: true, + remote: 'origin', + }), + new Branch({ + name: 'local', + onLocal: true, + onRemote: false, + remote: 'origin', + }), + new Branch({ + name: 'remote', + onLocal: false, + onRemote: true, + remote: 'origin', + }), + ]); + }); + }); + + describe('Test function: createBranchFrom', () => { + it('should succeed when there is no issue', async () => { + const result = await createBranchFrom('test', 'branch', 'main').then(() => 'success'); + + expect(result).toEqual('success'); + }); + + it('should fail in case of ENOTDIR', async () => { + const error = await createBranchFrom('test', 'enotdir', 'main').catch(({ message }) => message); + + expect(error).toEqual('ENOTDIR: File is not a directory.'); + }); + + it('should fail in case of ERROR', async () => { + const error = await createBranchFrom('test', 'error', 'main').catch(({ message }) => message); + + expect(error).toEqual('ERROR'); + }); + }); + + describe('Test function: gitUpdate', () => { + it('should call git pull', async () => { + await gitUpdate( + { + id: 'test', + git: { + username: 'username', + token: 'token', + }, + }, + 'branch', + true, + ); + + expect(git.pull).toBeCalled(); + }); + }); + + describe('Test function: getStatus', () => { + it('should be a success and return an array with one valid FileStatus', async () => { + expect(await getStatus()).toEqual([new FileStatus({ + path: 'test', + headStatus: 0, + workdirStatus: 1, + stageStatus: 2, + })]); + }); + }); + + describe('Test function: gitListFiles', () => { + it('should be a success and return an array with list of filePaths', async () => { + expect(await gitListFiles()).toEqual(['/test/file.txt']); + }); + }); + + describe('Test function: gitPush', () => { + it('should call git push and emit event', async () => { + await gitPush( + { + id: 'test', + git: { + username: 'username', + token: 'token', + }, + }, + 'branch', + true, + ); + + expect(git.push).toBeCalled(); + }); + }); + + describe('Test function: gitAdd', () => { + it('should call git add', async () => { + await gitAdd('projectId', 'filepath'); + + // expect(gitAddMock).toBeCalled(); + expect(git.add).toBeCalled(); + }); + }); + + describe('Test function: gitRemove', () => { + it('should call git remove', async () => { + await gitRemove('projectId', 'filepath'); + + // expect(gitRemoveMock).toBeCalled(); + expect(git.remove).toBeCalled(); + }); + }); + + describe('Test function: gitCommit', () => { + it('should call git commit and return SHA-1', async () => { + let result = await gitCommit('test', 'wip'); + expect(result).toEqual('SHA-1'); + + result = await gitCommit('test', 'wip', true); + expect(result).toEqual('SHA-1'); + }); + }); + + describe('Test function: gitGlobalUpload', () => { + it('should succeed', async () => { + const result = await gitGlobalUpload({ + git: { + username: 'username', + token: 'token', + }, + }).then(() => 'success'); + + expect(result).toEqual('success'); + }); + }); + + describe('Test function: gitLog', () => { + it('should return valid log', async () => { + const result = await gitLog('test', 'main'); + + expect(result).toEqual(['log']); + }); + + it('should call git log with default depth', async () => { + await gitLog('test', 'main'); + expect(git.log).toBeCalledWith(expect.objectContaining({ depth: 25 })); + }); + + it('should call git log with specified depth', async () => { + await gitLog('test', 'main', 1); + expect(git.log).toBeCalledWith(expect.objectContaining({ depth: 1 })); + }); + }); +}); From ace03efa243883c37fdfae3ff6e056ccaac0d7da Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Tue, 21 Nov 2023 10:56:54 +0100 Subject: [PATCH 2/5] Remove all git related function in Project.js --- src/composables/Project.js | 378 +------------------------ tests/unit/composables/Project.spec.js | 338 ++-------------------- 2 files changed, 28 insertions(+), 688 deletions(-) diff --git a/src/composables/Project.js b/src/composables/Project.js index 67183af42..491368c69 100644 --- a/src/composables/Project.js +++ b/src/composables/Project.js @@ -1,18 +1,21 @@ -import git from 'isomorphic-git'; -import http from 'isomorphic-git/http/web'; import * as BrowserFS from 'browserfs'; import { FileInformation, FileInput, } from 'leto-modelizer-plugin-core'; -import Branch from 'src/models/git/Branch'; -import FileStatus from 'src/models/git/FileStatus'; import { getFileInputs, getPlugins, getPluginByName, } from 'src/composables/PluginManager'; import Project from 'src/models/Project'; +import { + gitListFiles, + gitRemove, + gitInit, + gitAdd, + gitCommit, +} from 'src/composables/Git'; const fs = BrowserFS.BFSRequire('fs'); @@ -110,50 +113,6 @@ export function saveProject(project) { localStorage.setItem(PROJECT_STORAGE_KEY, JSON.stringify(projects)); } -/** - * Clone and save project from git in local storage. - * @param {Project} project - Project to save. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function importProject(project) { - return git.clone({ - fs, - http, - url: project.git.repository, - dir: `/${project.id}`, - onAuth: () => ({ - username: project.git.username, - password: project.git.token, - }), - corsProxy: '/cors-proxy', - singleBranch: true, - depth: 1, - }).then(() => saveProject(project)); -} - -/** - * Fetch project on git. - * Warning: It seems that `git.fetch` can throw unexpected error. - * @param {Project} project - Project to update. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitFetch(project) { - if (project.git?.repository) { - return git.fetch({ - fs, - http, - url: project.git.repository, - dir: `/${project.id}`, - onAuth: () => ({ - username: project.git.username, - password: project.git.token, - }), - corsProxy: '/cors-proxy', - }); - } - return Promise.resolve(); -} - /** * Check if path is directory or not. * @param {string} path - Path to check. @@ -263,19 +222,6 @@ export async function getProjectFolders(projectId) { return files; } -/** - * Get current branch of git project. - * @param {string} projectId - Id of project. - * @returns {Promise} Promise with current branch name on success otherwise error. - */ -export async function getCurrentBranch(projectId) { - return git.currentBranch({ - fs, - dir: `/${projectId}`, - fullname: false, - }); -} - /** * Get file content. * @param {string} projectId - Id of project. @@ -294,56 +240,6 @@ export async function readProjectFile(projectId, fileInformation) { return new FileInput({ path: fileInformation.path, content }); } -/** - * Get all branches of project. - * @param {string} projectId - Id of project. - * @returns {Promise} Promise with array of branches on success otherwise error. - */ -export async function getBranches(projectId) { - const dir = `/${projectId}`; - - const [local, remote] = await Promise.all([ - git.listBranches({ - fs, - dir, - }), - git.listBranches({ - fs, - dir, - remote: 'origin', - }), - ]); - - const branches = local.map((localBranch) => (new Branch({ - name: localBranch, - onLocal: true, - onRemote: remote.includes(localBranch), - remote: 'origin', - }))); - - // TODO: remove this when https://github.com/isomorphic-git/isomorphic-git/issues/1650 is resolve. - if (branches.length === 0) { - const currentBranch = await getCurrentBranch(projectId); - branches.push(new Branch({ - name: currentBranch, - onLocal: true, - onRemote: false, - remote: 'origin', - })); - } - - return branches.concat( - remote - .filter((remoteBranch) => !local.includes(remoteBranch)) - .map((remoteBranch) => (new Branch({ - name: remoteBranch, - onLocal: false, - onRemote: true, - remote: 'origin', - }))), - ).filter(({ name }) => name !== 'HEAD'); -} - /** * Create a new directory. * @param {string} path - Path of the folder to create. @@ -441,124 +337,6 @@ export async function appendProjectFile(projectId, file) { }); } -/** - * Get list of all the files in the current staging area. - * @param {string} projectId - Id of the project. - * @returns {Promise} Promise with array of filepaths on success otherwise an error. - */ -export async function gitListFiles(projectId) { - return git.listFiles({ - fs, - dir: `/${projectId}`, - }); -} - -/** - * Update remote origin, fetch and checkout the default branch. - * @param {Project} project - Project to update. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitAddRemote(project) { - await git.addRemote({ - fs, - dir: `/${project.id}`, - url: project.git.repository, - remote: 'origin', - force: true, - }); -} - -/** - * Checkout branch. - * @param {string} projectId - Id of project. - * @param {string} branch - Branch name to checkout. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitCheckout(projectId, branch) { - await git.checkout({ - fs, - dir: `/${projectId}`, - ref: branch, - }).catch(({ name }) => Promise.reject({ name })); -} - -/** - * Create branch from another branch. - * @param {string} projectId - Id of project. - * @param {string} newBranchName - New branch name. - * @param {string} branchName - Branch name. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function createBranchFrom(projectId, newBranchName, branchName) { - await git.branch({ - fs, - dir: `/${projectId}`, - ref: newBranchName, - object: branchName, - }).catch(({ name, message }) => { - if (message.indexOf('ENOTDIR: File is not a directory.') >= 0) { - return Promise.reject({ name: 'cannotLockRef', message }); - } - return Promise.reject({ name, message }); - }); -} - -/** - * Add untracked, unstaged or modified files. - * @param {string} projectId - Id of project. - * @param {string} filepath - Path of the file to add. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitAdd(projectId, filepath) { - return git.add({ - fs, - dir: `/${projectId}`, - filepath, - }); -} - -/** - * Add (stage) deleted files. - * @param {string} projectId - Id of project. - * @param {string} filepath - Path of the file to add. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitRemove(projectId, filepath) { - return git.remove({ - fs, - dir: `/${projectId}`, - filepath, - }); -} - -/** - * Update selected branch with git pull. - * @param {Project} project - Project to update. - * @param {string} branchName - Branch name. - * @param {boolean} fastForward - State of fast forward option. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitUpdate(project, branchName, fastForward) { - await git.pull({ - fs, - http, - dir: `/${project.id}`, - ref: branchName, - fastForward, - singleBranch: true, - onAuth: () => ({ - username: project.git.username, - password: project.git.token, - }), - // TODO: Change when we have user information. - author: { - name: 'LetoModelizer', - email: 'LetoModelizer@no-reply.com', - }, - corsProxy: '/cors-proxy', - }); -} - /** * Delete folder on fs. * @param {string} path - Path of folder to delete. @@ -701,146 +479,6 @@ export async function deleteDiagramFile(pluginName, projectId, filePath) { })); } -/** - * Get the status of all files. If filePaths is defined, get the status of the files - * that strictly or partially match the given filePaths. - * @param {string} projectId - Id of project. - * @param {string[]} filepaths - Limit the query to the given files and directories. - * @param {Function} filter - Filter to only return results whose filepath matches a given function. - * @returns {Promise} All files status. - */ -export async function getStatus(projectId, filepaths, filter) { - return git.statusMatrix({ - fs, - dir: `/${projectId}`, - filepaths, - filter, - }).then((files) => files - .map((file) => new FileStatus({ - path: file[0], - headStatus: file[1], - workdirStatus: file[2], - stageStatus: file[3], - }))); -} - -/** - * Push selected branch on server. - * @param {Project} project - Project. - * @param {string} branchName - Branch name. - * @param {boolean} force - State of force option. - * @returns {Promise} Promise with nothing on success otherwise an error. - */ -export async function gitPush(project, branchName, force) { - await git.push({ - fs, - http, - dir: `/${project.id}`, - remote: 'origin', - ref: branchName, - force, - onAuth: () => ({ - username: project.git.username, - password: project.git.token, - }), - corsProxy: '/cors-proxy', - }); -} - -/** - * Commit all staged files. - * @param {string} projectId - Id of project. - * @param {string} message - Commit message. - * @param {boolean} noUpdateBranchValue - If true, does not update the branch pointer - * after creating the commit. - * @returns {Promise} Promise with the SHA-1 object id of the newly created commit on success - * otherwise an error. - */ -export async function gitCommit(projectId, message, noUpdateBranchValue = false) { - return git.commit({ - fs, - dir: `/${projectId}`, - noUpdateBranch: noUpdateBranchValue, - // TODO: Change when we have user information. - author: { - name: 'LetoModelizer', - email: 'LetoModelizer@no-reply.com', - }, - message, - }); -} - -/** - * Add and commit all modifications on the new branch and push it. - * @param {object} project - Object containing all information about the project. - */ -export async function gitGlobalUpload(project) { - const nowDate = new Date(); - const newBranch = `leto-modelizer_${nowDate.getTime()}`; - const files = (await getStatus(project.id)); - const modifiedFiles = files - .filter((file) => file.hasUnstagedChanged - || file.isUntracked - || file.isUnstaged - || file.isStaged) - .map((file) => file.path); - - await gitAdd(project.id, modifiedFiles); - - /* Special case for deleted files. - Unlike usual git, we CAN NOT add (git add) a deleted file, - so here, the idea is to use the remove method of isomorphic-git to stage the - deleted files. - cf: https://github.com/isomorphic-git/isomorphic-git/issues/1099 - */ - const deletedfiles = files.filter((file) => file.isDeleted).map((file) => file.path); - - await gitRemove(project.id, deletedfiles); - - /* - Commiting BEFORE creating a new branch. - Again, due to some specific behavior in isomorphic-git, the modifications are not - transfered (unlike usual git) while creating a new branch or doing a checkout. - - Creating the (orphan) commit before, give the possibility to create a new branch from that commit. - Which will contains all modifications AND will be attached to the newly created branch. - */ - const sha1 = await gitCommit( - project.id, - `leto-modelizer ${nowDate.toDateString()}`, - true, - ); - - await createBranchFrom( - project.id, - newBranch, - sha1, - ); - await gitCheckout(project.id, newBranch); - await gitPush( - project, - newBranch, - true, - ); -} - -/** - * Get all logs from git log. - * @param {string} projectId - Id of project. - * @param {string} ref - The commit to begin walking backwards through the history from. - * @param {number} [depth] - Number of log to retrieve. - * @returns {Promise} Promise with logs on success otherwise an error. - * @see https://isomorphic-git.org/docs/en/log - */ -export async function gitLog(projectId, ref, depth = 25) { - return git.log({ - fs, - dir: `/${projectId}`, - depth, - ref, - }); -} - /** * Save project and initialize git in local storage. * @param {Project} project - Project to save. @@ -848,7 +486,7 @@ export async function gitLog(projectId, ref, depth = 25) { */ export async function initProject(project) { saveProject(project); - await git.init({ fs, dir: `/${project.id}` }); + await gitInit(project.id); await writeProjectFile(project.id, new FileInput({ path: 'README.md', content: `# ${project.id}\n`, diff --git a/tests/unit/composables/Project.spec.js b/tests/unit/composables/Project.spec.js index 2701b22b7..74e547474 100644 --- a/tests/unit/composables/Project.spec.js +++ b/tests/unit/composables/Project.spec.js @@ -7,13 +7,6 @@ import { initProject, getProjectFiles, readProjectFile, - gitAddRemote, - getCurrentBranch, - gitFetch, - getBranches, - gitCheckout, - createBranchFrom, - gitUpdate, createProjectFolder, writeProjectFile, appendProjectFile, @@ -23,15 +16,6 @@ import { deleteProjectDir, deleteProjectFile, deleteDiagramFile, - getStatus, - gitListFiles, - gitPush, - gitAdd, - gitRemove, - gitCommit, - gitLog, - gitGlobalUpload, - importProject, PROJECT_STORAGE_KEY, getAllModels, getModelFiles, @@ -41,61 +25,14 @@ import { extractProjectName, getProjectFolders, } from 'src/composables/Project'; +import { + gitInit, + gitAdd, + gitCommit, +} from 'src/composables/Git'; import { FileInformation, FileInput } from 'leto-modelizer-plugin-core'; -import Branch from 'src/models/git/Branch'; -import git from 'isomorphic-git'; -import FileStatus from 'src/models/git/FileStatus'; import Project from 'src/models/Project'; -jest.mock('isomorphic-git', () => ({ - init: jest.fn(() => Promise.resolve('init')), - clone: jest.fn(({ onAuth }) => { - onAuth(); - - return Promise.resolve('clone'); - }), - branch: jest.fn(({ ref }) => { - if (ref === 'error') { - return Promise.reject({ message: 'ERROR' }); - } - - if (ref === 'enotdir') { - return Promise.reject({ message: 'ENOTDIR: File is not a directory.' }); - } - - return Promise.resolve('branch'); - }), - addRemote: jest.fn(() => Promise.resolve('addRemote')), - fetch: jest.fn(), - checkout: jest.fn(() => Promise.resolve('checkout')), - listFiles: jest.fn(() => Promise.resolve(['/test/file.txt'])), - listBranches: jest.fn(({ remote }) => { - if (!remote) { - return Promise.resolve(['HEAD', 'main', 'local']); - } - - return Promise.resolve(['HEAD', 'main', 'remote']); - }), - resolveRef: jest.fn(() => Promise.resolve('resolveRef')), - readBlob: jest.fn(() => Promise.resolve({ blob: 'test' })), - currentBranch: jest.fn(() => Promise.resolve('main')), - pull: jest.fn(({ onAuth }) => { - onAuth(); - - return Promise.resolve('pull'); - }), - statusMatrix: jest.fn(() => Promise.resolve([['test', 0, 1, 2]])), - push: jest.fn(({ onAuth }) => { - onAuth(); - - return Promise.resolve('pull'); - }), - add: jest.fn(() => Promise.resolve('add')), - remove: jest.fn(() => Promise.resolve('remove')), - commit: jest.fn(() => Promise.resolve('SHA-1')), - log: jest.fn(() => Promise.resolve(['log'])), -})); - jest.mock('browserfs', () => ({ install: jest.fn(), configure: jest.fn(), @@ -166,8 +103,6 @@ jest.mock('browserfs', () => ({ jest.mock('src/composables/PluginManager', () => ({ getFileInputs: () => [], getPlugins: () => [], - getPluginTags: () => [], - isParsableFile: () => true, getPluginByName: jest.fn(() => ({ isParsable: () => true, configuration: { @@ -176,27 +111,17 @@ jest.mock('src/composables/PluginManager', () => ({ })), })); -describe('Test composable: Project', () => { - let gitAddMock; - let gitRemoveMock; - let gitAddRemoteMock; - let gitFetchMock; +jest.mock('src/composables/Git', () => ({ + gitListFiles: () => [], + gitRemove: () => Promise.resolve(), + gitInit: jest.fn(() => Promise.resolve()), + gitAdd: jest.fn(() => Promise.resolve()), + gitCommit: jest.fn(() => Promise.resolve()), +})); +describe('Test composable: Project', () => { beforeEach(() => { localStorage.clear(); - gitAddMock = jest.fn(); - gitRemoveMock = jest.fn(); - gitAddRemoteMock = jest.fn(); - gitFetchMock = jest.fn(({ onAuth }) => { - onAuth(); - - return Promise.resolve('fetch'); - }); - - git.add.mockImplementation(gitAddMock); - git.remove.mockImplementation(gitRemoveMock); - git.fetch.mockImplementation(gitFetchMock); - git.addRemote.mockImplementation(gitAddRemoteMock); }); describe('Test function: getProjects', () => { @@ -305,24 +230,15 @@ describe('Test composable: Project', () => { describe('Test function: initProject', () => { it('should init and create commit with default file', async () => { + gitInit.mockResolvedValueOnce(); + gitAdd.mockResolvedValueOnce(); + gitCommit.mockResolvedValueOnce(); + await initProject({ id: 'foo' }); - expect(git.init).toBeCalled(); - expect(git.add).toBeCalled(); - expect(git.commit).toBeCalled(); - }); - }); - describe('Test function: importProject', () => { - it('should call git clone', async () => { - await importProject({ - id: 'foo', - git: { - repository: 'test', - username: 'test', - token: 'test', - }, - }); - expect(git.clone).toBeCalled(); + expect(gitInit).toHaveBeenCalledWith('foo'); + expect(gitAdd).toHaveBeenCalledWith('foo', 'README.md'); + expect(gitCommit).toHaveBeenCalledWith('foo', 'Initial commit.'); }); }); @@ -396,55 +312,6 @@ describe('Test composable: Project', () => { }); }); - describe('Test function: gitAddRemote', () => { - it('should call all needed git method', async () => { - localStorage.setItem(PROJECT_STORAGE_KEY, JSON.stringify({ - test: { - id: 'test', - git: { - repository: 'test', - username: 'test', - token: 'test', - }, - }, - })); - await gitAddRemote({ - id: 'test', - git: { - repository: 'test', - username: 'test', - token: 'test', - }, - }); - - expect(gitAddRemoteMock).toBeCalled(); - }); - }); - - describe('Test function: gitFetch', () => { - it('should not call git.fetch', async () => { - const result = await gitFetch({}); - - expect(gitFetchMock).not.toBeCalled(); - expect(result).toBeUndefined(); - }); - - it('should call git.fetch', async () => { - const result = await gitFetch({ git: { repository: 'test' } }); - - expect(gitFetchMock).toBeCalled(); - expect(result).toEqual('fetch'); - }); - }); - - describe('Test function: gitCheckout', () => { - it('should emit checkout event', async () => { - const result = await gitCheckout('projectId', 'test').then(() => 'success'); - - expect(result).toEqual('success'); - }); - }); - describe('Test function: getProjectFiles', () => { it('should return file information array', async () => { localStorage.setItem(PROJECT_STORAGE_KEY, JSON.stringify({ @@ -488,79 +355,6 @@ describe('Test composable: Project', () => { }); }); - describe('Test function: getCurrentBranch', () => { - it('should return current branch name', async () => { - const result = await getCurrentBranch('test'); - - expect(result).toEqual('main'); - }); - }); - - describe('Test function: getBranches', () => { - it('should return valid branches', async () => { - const branches = await getBranches('test'); - - expect(branches).toEqual([ - new Branch({ - name: 'main', - onLocal: true, - onRemote: true, - remote: 'origin', - }), - new Branch({ - name: 'local', - onLocal: true, - onRemote: false, - remote: 'origin', - }), - new Branch({ - name: 'remote', - onLocal: false, - onRemote: true, - remote: 'origin', - }), - ]); - }); - }); - - describe('Test function: createBranchFrom', () => { - it('should succeed when there is no issue', async () => { - const result = await createBranchFrom('test', 'branch', 'main').then(() => 'success'); - - expect(result).toEqual('success'); - }); - - it('should fail in case of ENOTDIR', async () => { - const error = await createBranchFrom('test', 'enotdir', 'main').catch(({ message }) => message); - - expect(error).toEqual('ENOTDIR: File is not a directory.'); - }); - - it('should fail in case of ERROR', async () => { - const error = await createBranchFrom('test', 'error', 'main').catch(({ message }) => message); - - expect(error).toEqual('ERROR'); - }); - }); - - describe('Test function: gitUpdate', () => { - it('should call git pull', async () => { - await gitUpdate( - { - id: 'test', - git: { - username: 'username', - token: 'token', - }, - }, - 'branch', - true, - ); - - expect(git.pull).toBeCalled(); - }); - }); - describe('Test function: rmDir', () => { it('should be a success on good path', async () => { expect(await rmDir('ok')).toBeUndefined(); @@ -617,98 +411,6 @@ describe('Test composable: Project', () => { }); }); - describe('Test function: getStatus', () => { - it('should be a success and return an array with one valid FileStatus', async () => { - expect(await getStatus()).toEqual([new FileStatus({ - path: 'test', - headStatus: 0, - workdirStatus: 1, - stageStatus: 2, - })]); - }); - }); - - describe('Test function: gitListFiles', () => { - it('should be a success and return an array with list of filePaths', async () => { - expect(await gitListFiles()).toEqual(['/test/file.txt']); - }); - }); - - describe('Test function: gitPush', () => { - it('should call git push and emit event', async () => { - await gitPush( - { - id: 'test', - git: { - username: 'username', - token: 'token', - }, - }, - 'branch', - true, - ); - - expect(git.push).toBeCalled(); - }); - }); - - describe('Test function: gitAdd', () => { - it('should call git add', async () => { - await gitAdd('projectId', 'filepath'); - - expect(gitAddMock).toBeCalled(); - }); - }); - - describe('Test function: gitRemove', () => { - it('should call git remove', async () => { - await gitRemove('projectId', 'filepath'); - - expect(gitRemoveMock).toBeCalled(); - }); - }); - - describe('Test function: gitCommit', () => { - it('should call git commit and return SHA-1', async () => { - let result = await gitCommit('test', 'wip'); - expect(result).toEqual('SHA-1'); - - result = await gitCommit('test', 'wip', true); - expect(result).toEqual('SHA-1'); - }); - }); - - describe('Test function: gitGlobalUpload', () => { - it('should succeed', async () => { - const result = await gitGlobalUpload({ - git: { - username: 'username', - token: 'token', - }, - }).then(() => 'success'); - - expect(result).toEqual('success'); - }); - }); - - describe('Test function: gitLog', () => { - it('should return valid log', async () => { - const result = await gitLog('test', 'main'); - - expect(result).toEqual(['log']); - }); - - it('should call git log with default depth', async () => { - await gitLog('test', 'main'); - expect(git.log).toBeCalledWith(expect.objectContaining({ depth: 25 })); - }); - - it('should call git log with specified depth', async () => { - await gitLog('test', 'main', 1); - expect(git.log).toBeCalledWith(expect.objectContaining({ depth: 1 })); - }); - }); - describe('Test function: getAllModels', () => { it('should return an empty array', async () => { expect(await getAllModels('test')).toEqual([]); From a697e30234867c8e2b6b7249bacbbe2821d6ac85 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Tue, 21 Nov 2023 11:09:39 +0100 Subject: [PATCH 3/5] Update components' import and test --- src/components/FileExplorer.vue | 3 ++- src/components/NavigationBar.vue | 6 ++---- src/components/card/GitBranchCard.vue | 2 +- src/components/dialog/GitCommitDialog.vue | 2 +- src/components/dialog/GitLogDialog.vue | 2 +- src/components/dialog/GitStatusDialog.vue | 2 +- src/components/editor/MonacoEditor.vue | 2 +- .../form/CreateProjectTemplateForm.vue | 2 +- src/components/form/GitAddRemoteForm.vue | 2 +- src/components/form/GitCommitForm.vue | 2 +- src/components/form/GitNewBranchForm.vue | 2 +- src/components/form/GitPushForm.vue | 3 ++- src/components/form/GitUpdateForm.vue | 3 ++- src/components/form/ImportProjectForm.vue | 2 +- src/components/form/RenameFileForm.vue | 3 ++- src/components/menu/FileExplorerActionMenu.vue | 2 +- src/components/menu/GitBranchActionMenu.vue | 2 +- src/components/menu/GitBranchMenu.vue | 4 ++-- src/components/tab/FileTabs.vue | 3 ++- tests/unit/components/FileExplorer.spec.js | 9 ++++++--- tests/unit/components/NavigationBar.spec.js | 9 ++++++--- .../unit/components/card/GitBranchCard.spec.js | 6 +++--- .../components/dialog/GitCommitDialog.spec.js | 2 +- .../unit/components/dialog/GitLogDialog.spec.js | 4 ++-- .../components/dialog/GitStatusDialog.spec.js | 2 +- .../unit/components/editor/MonacoEditor.spec.js | 3 +++ .../form/CreateProjectTemplateForm.spec.js | 10 +++++++--- .../components/form/GitAddRemoteForm.spec.js | 5 ++++- .../unit/components/form/GitCommitForm.spec.js | 2 +- .../components/form/GitNewBranchForm.spec.js | 6 +++--- tests/unit/components/form/GitPushForm.spec.js | 3 +++ .../unit/components/form/GitUpdateForm.spec.js | 3 +++ .../components/form/ImportProjectForm.spec.js | 17 ++++++++++------- .../unit/components/form/RenameFileForm.spec.js | 5 ++++- .../menu/FileExplorerActionMenu.spec.js | 2 +- .../components/menu/GitBranchActionMenu.spec.js | 2 +- .../unit/components/menu/GitBranchMenu.spec.js | 9 ++++++--- tests/unit/components/tab/FileTabs.spec.js | 10 +++++++--- 38 files changed, 98 insertions(+), 60 deletions(-) diff --git a/src/components/FileExplorer.vue b/src/components/FileExplorer.vue index dedc09d29..e776d9f12 100644 --- a/src/components/FileExplorer.vue +++ b/src/components/FileExplorer.vue @@ -66,7 +66,8 @@ import GitEvent from 'src/composables/events/GitEvent'; import FileExplorerActionButton from 'src/components/FileExplorerActionButton.vue'; import FileName from 'src/components/FileName.vue'; import { getTree, updateFileInformation } from 'src/composables/FileExplorer'; -import { getProjectFiles, getStatus } from 'src/composables/Project'; +import { getProjectFiles } from 'src/composables/Project'; +import { getStatus } from 'src/composables/Git'; import { FileInformation } from 'leto-modelizer-plugin-core'; import FileStatus from 'src/models/git/FileStatus'; import { useRoute } from 'vue-router'; diff --git a/src/components/NavigationBar.vue b/src/components/NavigationBar.vue index 7bc7e61a7..0e8209176 100644 --- a/src/components/NavigationBar.vue +++ b/src/components/NavigationBar.vue @@ -69,10 +69,8 @@ import { useI18n } from 'vue-i18n'; import ModelizerSettingsMenu from 'components/menu/ModelizerSettingsMenu.vue'; import FileEvent from 'src/composables/events/FileEvent'; import { Notify } from 'quasar'; -import { - getProjectById, - gitGlobalUpload, -} from 'src/composables/Project'; +import { getProjectById } from 'src/composables/Project'; +import { gitGlobalUpload } from 'src/composables/Git'; import GitEvent from 'src/composables/events/GitEvent'; import { useRoute, useRouter } from 'vue-router'; diff --git a/src/components/card/GitBranchCard.vue b/src/components/card/GitBranchCard.vue index 1c47c7974..50b7693ff 100644 --- a/src/components/card/GitBranchCard.vue +++ b/src/components/card/GitBranchCard.vue @@ -19,7 +19,7 @@