├── .nvmrc ├── test ├── global.d.ts ├── constants.test.ts ├── jest.setup.ts ├── clone.test.ts ├── lockFiles.test.ts ├── commitAndSync.test.ts ├── utils.ts ├── forcePull.test.ts ├── constants.ts ├── sync.test.ts ├── initGit.test.ts ├── credential.test.ts └── inspect.test.ts ├── pnpm-workspace.yaml ├── .vscode └── settings.json ├── static ├── README.md ├── favicon.ico └── images │ └── Logo.webp ├── src ├── defaultGitInfo.ts ├── index.ts ├── utils.ts ├── init.ts ├── credential.ts ├── clone.ts ├── initGit.ts ├── interface.ts ├── forcePull.ts ├── errors.ts ├── commitAndSync.ts ├── sync.ts └── inspect.ts ├── .gitignore ├── jest.config.js ├── typedoc.json ├── dprint.json ├── tsconfig.eslint.json ├── scripts └── copy-readme.mjs ├── LICENSE ├── tsconfig.json ├── .github └── workflows │ └── deploy-docs-to-github-pages.yml ├── eslint.config.mjs ├── package.json ├── docusaurus.config.ts └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /test/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - dugite 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "dugite" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # Static 2 | 3 | Files used by doc site generated by `docusaurus.config.ts`. 4 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiddly-gittly/git-sync-js/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/images/Logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiddly-gittly/git-sync-js/HEAD/static/images/Logo.webp -------------------------------------------------------------------------------- /src/defaultGitInfo.ts: -------------------------------------------------------------------------------- 1 | export const defaultGitInfo = { 2 | email: 'gitsync@gmail.com', 3 | gitUserName: 'gitsync', 4 | branch: 'main', 5 | remote: 'origin', 6 | }; 7 | -------------------------------------------------------------------------------- /test/constants.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultGitInfo } from '../src/defaultGitInfo'; 2 | 3 | describe('defaultGitInfo', () => { 4 | test('default branch now should be main', () => { 5 | expect(defaultGitInfo.branch).toBe('main'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | flycheck_* 4 | \#* 5 | *~ 6 | *.*~ 7 | .yo-rc.json 8 | /dist/ 9 | /.nyc_output 10 | /coverage.lcov 11 | /scratch 12 | /secrets 13 | .dccache 14 | dist/ 15 | test/mockRepo 16 | test/mockRepo2 17 | test/mockUpstreamRepo 18 | docs/api 19 | docs/README.md 20 | .docusaurus 21 | build -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** primary functions */ 2 | export * from './clone'; 3 | export * from './commitAndSync'; 4 | export * from './forcePull'; 5 | export * from './initGit'; 6 | /** utils */ 7 | export * from './credential'; 8 | export * from './defaultGitInfo'; 9 | export * from './errors'; 10 | export * from './inspect'; 11 | export * from './interface'; 12 | export * from './sync'; 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | // on slow windows machine with HDD, some test will timeout. 4 | testTimeout: 120_000, 5 | preset: 'ts-jest', 6 | testEnvironment: 'node', 7 | setupFilesAfterEnv: ['jest-extended/all', './test/jest.setup.ts'], 8 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 9 | }; 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src/"], 4 | "entryPointStrategy": "expand", 5 | "out": "docs/api", 6 | "compilerOptions": { 7 | "strictNullChecks": false, 8 | "skipLibCheck": true, 9 | "rootDir": "." 10 | }, 11 | "readme": "none", 12 | "entryDocument": "API.md", 13 | "plugin": ["typedoc-plugin-markdown"] 14 | } 15 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { IGitResult, IGitStringResult } from 'dugite'; 2 | 3 | export const getGitUrlWithGitSuffix = (url: string): string => `${url}.git`; 4 | export const getGitUrlWithOutGitSuffix = (url: string): string => url.replace(/\.git$/, ''); 5 | 6 | export const toGitOutputString = (value: string | Buffer): string => (typeof value === 'string' ? value : value.toString('utf8')); 7 | 8 | export const toGitStringResult = (result: IGitResult): IGitStringResult => ({ 9 | ...result, 10 | stdout: toGitOutputString(result.stdout), 11 | stderr: toGitOutputString(result.stderr), 12 | }); 13 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 180, 3 | "typescript": { 4 | "quoteProps": "asNeeded", 5 | "quoteStyle": "preferSingle", 6 | "binaryExpression.operatorPosition": "sameLine" 7 | }, 8 | "json": {}, 9 | "markdown": {}, 10 | "includes": [ 11 | "**/*.{ts,tsx,js,jsx,cjs,mjs,json,md}", 12 | "./*.json", 13 | "./*.js", 14 | "packages/*/.*.js" 15 | ], 16 | "excludes": ["**/node_modules", "**/*-lock.json"], 17 | "plugins": [ 18 | "https://plugins.dprint.dev/typescript-0.84.4.wasm", 19 | "https://plugins.dprint.dev/json-0.17.2.wasm", 20 | "https://plugins.dprint.dev/markdown-0.15.2.wasm" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "allowJs": true 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "src/**/*.js", 10 | "src/**/*.tsx", 11 | "src/**/*.jsx", 12 | "public/**/*.ts", 13 | "public/**/*.js", 14 | "test/**/*.ts", 15 | "scripts/**/*.ts", 16 | "scripts/**/*.js", 17 | "scripts/**/*.mjs", 18 | "src/**/*.d.ts", 19 | "public/**/*.d.ts", 20 | "./*.json", 21 | "./*.js", 22 | "./*.ts", 23 | "./*.*.js" 24 | ], 25 | "exclude": ["template/**/*.js", "src/services/libs/i18n/i18next-electron-fs-backend.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /scripts/copy-readme.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | const sourcePath = './README.md'; 4 | const destinationPath = './docs/README.md'; 5 | 6 | // Read the content of the source file 7 | fs.readFile(sourcePath, 'utf8', (err, data) => { 8 | if (err) { 9 | console.error('Error reading file:', err); 10 | return; 11 | } 12 | 13 | // Replace markdown links of the format '[text](./docs/xxx)' with '[text](xxx)' 14 | const updatedData = data.replaceAll(/]\(\.\/docs\/(.*?)\)/g, ']($1)'); 15 | 16 | // Write the updated content to the destination file 17 | fs.writeFile(destinationPath, updatedData, 'utf8', (err) => { 18 | if (err) { 19 | console.error('Error writing file:', err); 20 | return; 21 | } 22 | console.log(`File copied from ${sourcePath} to ${destinationPath} with internal links updated.`); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 lin onetwo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "test/**/*", "./*.js"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "lib": ["ESNext"], 8 | "moduleResolution": "node", 9 | "outDir": "./dist", 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noUncheckedIndexedAccess": true, 18 | "noUnusedLocals": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "downlevelIteration": true, 21 | "esModuleInterop": true, 22 | "resolveJsonModule": true, 23 | "types": ["jest"], /* Type declaration files to be included in compilation. */ 24 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 25 | "baseUrl": "./", 26 | "paths": { 27 | "*": ["src/@types/*"] 28 | } 29 | }, 30 | "files": ["test/global.d.ts"] 31 | } 32 | -------------------------------------------------------------------------------- /test/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { defaultGitInfo } from '../src/defaultGitInfo'; 3 | import { initGitWithBranch } from '../src/init'; 4 | import { dir, dir2, setGlobalConstants, upstreamDir } from './constants'; 5 | 6 | beforeEach(async () => { 7 | setGlobalConstants(); 8 | await resetMockGitRepositories(); 9 | await setUpMockGitRepositories(); 10 | }, 120_000); 11 | 12 | // afterAll(async () => { 13 | // return await resetMockGitRepositories(); 14 | // }); 15 | 16 | export async function setUpMockGitRepositories() { 17 | await Promise.all([ 18 | // simulate situation that local repo is initialized first, and upstream repo (Github) is empty & bare, and is initialized later 19 | fs.mkdirp(dir).then(() => initGitWithBranch(dir, defaultGitInfo.branch, { initialCommit: true, gitUserName: defaultGitInfo.gitUserName, email: defaultGitInfo.email })), 20 | fs.mkdirp(upstreamDir).then(() => initGitWithBranch(upstreamDir, defaultGitInfo.branch, { initialCommit: false, bare: true })), 21 | ]); 22 | } 23 | 24 | export async function resetMockGitRepositories() { 25 | await Promise.all([ 26 | fs.remove(dir), 27 | fs.remove(dir2), 28 | fs.remove(upstreamDir), 29 | ]); 30 | } 31 | -------------------------------------------------------------------------------- /test/clone.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { clone } from '../src/clone'; 3 | import { defaultGitInfo } from '../src/defaultGitInfo'; 4 | import { getDefaultBranchName, getSyncState, hasGit, haveLocalChanges, SyncState } from '../src/inspect'; 5 | import { dir, exampleToken, gitDirectory, upstreamDir } from './constants'; 6 | import { addAndCommitUsingDugite, addSomeFiles, anotherRepo2PushSomeFiles, createAndSyncRepo2ToRemote } from './utils'; 7 | 8 | describe('clone', () => { 9 | beforeEach(async () => { 10 | // remove dir's .git folder in this test suit, so we have a clean folder to clone 11 | await fs.remove(gitDirectory); 12 | // repo2 modify the remote, make us behind 13 | await createAndSyncRepo2ToRemote(); 14 | await anotherRepo2PushSomeFiles(); 15 | }); 16 | 17 | describe('with upstream', () => { 18 | test('equal to upstream after clone', async () => { 19 | await clone({ 20 | dir, 21 | userInfo: { ...defaultGitInfo, accessToken: exampleToken }, 22 | remoteUrl: upstreamDir, 23 | }); 24 | expect(await hasGit(dir)).toBe(true); 25 | expect(await haveLocalChanges(dir)).toBe(false); 26 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 27 | expect(await getDefaultBranchName(dir)).toBe(defaultGitInfo.branch); 28 | }); 29 | 30 | test('equal to committed upstream', async () => { 31 | // modify upstream 32 | await addSomeFiles(upstreamDir); 33 | await addAndCommitUsingDugite(upstreamDir); 34 | 35 | await clone({ 36 | dir, 37 | userInfo: { ...defaultGitInfo, accessToken: exampleToken }, 38 | remoteUrl: upstreamDir, 39 | }); 40 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs-to-github-pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy Docs to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: [master] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | env: 24 | # Hosted GitHub runners have 7 GB of memory available, let's use 6 GB 25 | NODE_OPTIONS: --max-old-space-size=6144 26 | 27 | jobs: 28 | # Single deploy job since we're just deploying 29 | deploy: 30 | environment: 31 | name: github-pages 32 | url: ${{ steps.deployment.outputs.page_url }} 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v3 37 | - uses: pnpm/action-setup@v2 38 | with: 39 | version: 8 40 | - name: Set up Node.js 41 | uses: actions/setup-node@v3 42 | with: 43 | node-version: 20.x 44 | cache: pnpm 45 | - name: Install dependencies 46 | run: pnpm i 47 | - name: Gen API Docs 48 | run: pnpm run docs:generate 49 | - name: Build Docs Site 50 | run: pnpm run docs:build 51 | - name: Setup Pages 52 | uses: actions/configure-pages@v3 53 | - name: Upload artifact 54 | uses: actions/upload-pages-artifact@v2 55 | with: 56 | # Upload the folder /build generated by docusaurus 57 | path: build 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v2 61 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tidgiConfig from 'eslint-config-tidgi'; 2 | import { dirname } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | export default [ 9 | { 10 | ignores: ['docusaurus.config.ts', 'eslint.config.mjs', 'jest.config.js', 'scripts/**/*.mjs', 'dist/**', 'node_modules/**'], 11 | }, 12 | ...tidgiConfig, 13 | { 14 | languageOptions: { 15 | parserOptions: { 16 | projectService: { 17 | allowDefaultProject: [ 18 | './*.js', 19 | './*.mjs', 20 | './*.cjs', 21 | './*.*.js', 22 | './*.*.ts', 23 | './*.*.mjs', 24 | './*.config.ts', 25 | './*.config.js', 26 | './*.config.mjs', 27 | './docusaurus.config.ts', 28 | './eslint.config.mjs', 29 | './jest.config.js', 30 | './scripts/*.mjs', 31 | ], 32 | }, 33 | tsconfigRootDir: __dirname, 34 | }, 35 | }, 36 | rules: { 37 | '@typescript-eslint/no-unnecessary-condition': 'off', 38 | '@typescript-eslint/require-await': 'off', 39 | '@typescript-eslint/unified-signatures': 'off', 40 | 'unicorn/prevent-abbreviations': 'off', 41 | 'unicorn/filename-case': 'off', 42 | }, 43 | }, 44 | { 45 | files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '*.env.d.ts'], 46 | rules: { 47 | '@typescript-eslint/unbound-method': 'off', 48 | 'unicorn/prevent-abbreviations': 'off', 49 | '@typescript-eslint/no-unsafe-call': 'off', 50 | '@typescript-eslint/no-unsafe-member-access': 'off', 51 | '@typescript-eslint/no-unsafe-argument': 'off', 52 | '@typescript-eslint/no-unsafe-assignment': 'off', 53 | '@typescript-eslint/no-explicit-any': 'warn', 54 | }, 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | import { defaultGitInfo } from './defaultGitInfo'; 5 | 6 | export interface IGitInitOptions { 7 | /** 8 | * Whether create a bare repo, useful as an upstream repo 9 | */ 10 | bare?: boolean; 11 | 12 | /** 13 | * Default to true, to try to fix https://stackoverflow.com/questions/12267912/git-error-fatal-ambiguous-argument-head-unknown-revision-or-path-not-in-the 14 | * 15 | * Following techniques are not working: 16 | * 17 | * ```js 18 | * await exec(['symbolic-ref', 'HEAD', `refs/heads/${branch}`], dir); 19 | * await exec(['checkout', `-b`, branch], dir); 20 | * ``` 21 | * 22 | * This works: 23 | * https://stackoverflow.com/a/51527691/4617295 24 | */ 25 | initialCommit?: boolean; 26 | 27 | /** 28 | * Git user name for the initial commit 29 | */ 30 | gitUserName?: string; 31 | 32 | /** 33 | * Git user email for the initial commit 34 | */ 35 | email?: string; 36 | } 37 | 38 | /** 39 | * Init and immediately checkout the branch, other wise the branch will be HEAD, which is annoying in the later steps 40 | */ 41 | export async function initGitWithBranch(dir: string, branch = defaultGitInfo.branch, options?: IGitInitOptions): Promise { 42 | if (options?.bare === true) { 43 | const bareGitPath = path.join(dir, '.git'); 44 | await fs.mkdirp(bareGitPath); 45 | await exec(['init', `--initial-branch=${branch}`, '--bare'], bareGitPath); 46 | } else { 47 | await exec(['init', `--initial-branch=${branch}`], dir); 48 | } 49 | 50 | if (options?.initialCommit !== false) { 51 | const gitUserName = options?.gitUserName ?? defaultGitInfo.gitUserName; 52 | const email = options?.email ?? defaultGitInfo.email; 53 | await exec( 54 | ['commit', `--allow-empty`, '-n', '-m', 'Initial commit when init a new git.'], 55 | dir, 56 | { 57 | env: { 58 | ...process.env, 59 | GIT_COMMITTER_NAME: gitUserName, 60 | GIT_COMMITTER_EMAIL: email, 61 | GIT_AUTHOR_NAME: gitUserName, 62 | GIT_AUTHOR_EMAIL: email, 63 | }, 64 | }, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/credential.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import { trim } from 'lodash'; 3 | import { getRemoteUrl } from './inspect'; 4 | 5 | export enum ServiceType { 6 | Github = 'github', 7 | } 8 | 9 | // TODO: support folderLocation as rawUrl like `/Users/linonetwo/Desktop/repo/git-sync-js/test/mockUpstreamRepo/credential` for test, or gitlab url. 10 | export const getGitHubUrlWithCredential = (rawUrl: string, username: string, accessToken: string): string => 11 | trim(rawUrl.replaceAll('\n', '').replace('https://github.com/', `https://${username}:${accessToken}@github.com/`)); 12 | const getGitHubUrlWithOutCredential = (urlWithCredential: string): string => trim(urlWithCredential.replace(/.+@/, 'https://')); 13 | 14 | /** 15 | * Add remote with credential 16 | * @param {string} directory 17 | * @param {string} remoteUrl 18 | * @param {{ login: string, email: string, accessToken: string }} userInfo 19 | */ 20 | export async function credentialOn( 21 | directory: string, 22 | remoteUrl: string, 23 | userName: string, 24 | accessToken: string, 25 | remoteName: string, 26 | serviceType = ServiceType.Github, 27 | ): Promise { 28 | let gitUrlWithCredential; 29 | switch (serviceType) { 30 | case ServiceType.Github: { 31 | gitUrlWithCredential = getGitHubUrlWithCredential(remoteUrl, userName, accessToken); 32 | break; 33 | } 34 | } 35 | await exec(['remote', 'add', remoteName, gitUrlWithCredential], directory); 36 | await exec(['remote', 'set-url', remoteName, gitUrlWithCredential], directory); 37 | } 38 | /** 39 | * Add remote without credential 40 | * @param {string} directory 41 | * @param {string} githubRepoUrl 42 | * @param {{ login: string, email: string, accessToken: string }} userInfo 43 | */ 44 | export async function credentialOff(directory: string, remoteName: string, remoteUrl?: string, serviceType = ServiceType.Github): Promise { 45 | const gitRepoUrl = remoteUrl ?? (await getRemoteUrl(directory, remoteName)); 46 | let gitUrlWithOutCredential; 47 | switch (serviceType) { 48 | case ServiceType.Github: { 49 | gitUrlWithOutCredential = getGitHubUrlWithOutCredential(gitRepoUrl); 50 | break; 51 | } 52 | } 53 | await exec(['remote', 'set-url', remoteName, gitUrlWithOutCredential], directory); 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-sync-js", 3 | "version": "2.3.1", 4 | "description": "JS implementation for Git-Sync, a handy script that backup your notes in a git repo to the remote git services.", 5 | "homepage": "https://github.com/linonetwo/git-sync-js", 6 | "bugs": { 7 | "url": "https://github.com/linonetwo/git-sync-js/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/linonetwo/git-sync-js.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Lin Onetwo (https://github.com/linonetwo)", 15 | "main": "dist/src/index.js", 16 | "types": "dist/src/index.d.ts", 17 | "files": [ 18 | "dist/src/" 19 | ], 20 | "scripts": { 21 | "prepublishOnly": "npm run clean && npm run test && npm run lint && npm run compile", 22 | "test": "npm run clean:test && jest", 23 | "clean": "rimraf --no-glob dist", 24 | "clean:test": "rimraf test/mockRepo test/mockRepo2 test/mockUpstreamRepo", 25 | "compile": "tsc", 26 | "docs": "docs-ts", 27 | "lint": "eslint --ext ts .", 28 | "lint:fix": "eslint --ext ts --fix .", 29 | "check": "tsc --noEmit --skipLibCheck", 30 | "docs:build": "docusaurus build", 31 | "docs:dev": "docusaurus start", 32 | "docs:generate": "npm run docs:generate:api && npm run docs:generate:copy", 33 | "docs:generate:api": "rimraf docs/api && typedoc --options typedoc.json", 34 | "docs:generate:copy": "zx scripts/copy-readme.mjs" 35 | }, 36 | "dependencies": { 37 | "dugite": "3.0.0-rc12", 38 | "fs-extra": "^11.2.0", 39 | "lodash": "^4.17.21" 40 | }, 41 | "devDependencies": { 42 | "@docusaurus/core": "^3.0.1", 43 | "@docusaurus/preset-classic": "^3.0.1", 44 | "@docusaurus/types": "^3.0.1", 45 | "@mdx-js/react": "^3.0.0", 46 | "@types/fs-extra": "^11.0.4", 47 | "@types/jest": "^29.5.11", 48 | "@types/lodash": "^4.14.202", 49 | "@types/node": "^20.10.6", 50 | "docs-ts": "^0.8.0", 51 | "dprint": "^0.45.0", 52 | "eslint-config-tidgi": "latest", 53 | "eslint-plugin-jest-extended": "^2.0.0", 54 | "jest": "^29.7.0", 55 | "jest-extended": "^4.0.2", 56 | "jest-matcher-utils": "^29.7.0", 57 | "prism-react-renderer": "^2.3.1", 58 | "react": "^18.2.0", 59 | "react-dom": "^18.2.0", 60 | "rimraf": "^5.0.5", 61 | "ts-jest": "^29.1.1", 62 | "typedoc": "^0.25.4", 63 | "typedoc-plugin-markdown": "^3.17.1", 64 | "typescript": "^5.3.3", 65 | "zx": "^7.2.3" 66 | }, 67 | "keywords": [ 68 | "git,sync,notebook,commit,rebase,note" 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /src/clone.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import { truncate } from 'lodash'; 3 | import { credentialOff, credentialOn } from './credential'; 4 | import { defaultGitInfo as defaultDefaultGitInfo } from './defaultGitInfo'; 5 | import { GitPullPushError, SyncParameterMissingError } from './errors'; 6 | import { initGitWithBranch } from './init'; 7 | import { getRemoteName } from './inspect'; 8 | import { GitStep, IGitUserInfos, ILogger } from './interface'; 9 | import { toGitStringResult } from './utils'; 10 | 11 | export async function clone(options: { 12 | /** Optional fallback of userInfo. If some info is missing in userInfo, will use defaultGitInfo instead. */ 13 | defaultGitInfo?: typeof defaultDefaultGitInfo; 14 | /** wiki folder path, can be relative, should exist before function call */ 15 | dir: string; 16 | logger?: ILogger; 17 | /** the storage service url we are sync to, for example your github repo url */ 18 | remoteUrl?: string; 19 | /** user info used in the commit message */ 20 | userInfo?: IGitUserInfos; 21 | }): Promise { 22 | const { dir, remoteUrl, userInfo, logger, defaultGitInfo = defaultDefaultGitInfo } = options; 23 | const { gitUserName, branch } = userInfo ?? defaultGitInfo; 24 | const { accessToken } = userInfo ?? {}; 25 | 26 | if (accessToken === '' || accessToken === undefined) { 27 | throw new SyncParameterMissingError('accessToken'); 28 | } 29 | if (remoteUrl === '' || remoteUrl === undefined) { 30 | throw new SyncParameterMissingError('remoteUrl'); 31 | } 32 | 33 | const logProgress = (step: GitStep): unknown => 34 | logger?.info(step, { 35 | functionName: 'clone', 36 | step, 37 | dir, 38 | remoteUrl, 39 | }); 40 | const logDebug = (message: string, step: GitStep): unknown => 41 | logger?.debug(message, { 42 | functionName: 'clone', 43 | step, 44 | dir, 45 | remoteUrl, 46 | }); 47 | 48 | logProgress(GitStep.PrepareCloneOnlineWiki); 49 | 50 | logDebug( 51 | JSON.stringify({ 52 | remoteUrl, 53 | gitUserName, 54 | accessToken: truncate(accessToken, { 55 | length: 24, 56 | }), 57 | }), 58 | GitStep.PrepareCloneOnlineWiki, 59 | ); 60 | logDebug(`Running git init for clone in dir ${dir}`, GitStep.PrepareCloneOnlineWiki); 61 | await initGitWithBranch(dir, branch, { initialCommit: false }); 62 | const remoteName = await getRemoteName(dir, branch); 63 | logDebug(`Successfully Running git init for clone in dir ${dir}`, GitStep.PrepareCloneOnlineWiki); 64 | logProgress(GitStep.StartConfiguringGithubRemoteRepository); 65 | await credentialOn(dir, remoteUrl, gitUserName, accessToken, remoteName); 66 | try { 67 | logProgress(GitStep.StartFetchingFromGithubRemote); 68 | const { stderr: pullStdError, exitCode } = toGitStringResult(await exec(['pull', remoteName, `${branch}:${branch}`], dir)); 69 | if (exitCode === 0) { 70 | logProgress(GitStep.SynchronizationFinish); 71 | } else { 72 | throw new GitPullPushError(options, pullStdError); 73 | } 74 | } finally { 75 | await credentialOff(dir, remoteName, remoteUrl); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/lockFiles.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { checkGitLockFiles, removeGitLockFiles } from '../src/inspect'; 4 | import { dir, gitDirectory } from './constants'; 5 | 6 | describe('Git lock files', () => { 7 | describe('checkGitLockFiles', () => { 8 | test('returns empty array when no lock files exist', async () => { 9 | const lockFiles = await checkGitLockFiles(dir); 10 | expect(lockFiles).toEqual([]); 11 | }); 12 | 13 | test('detects index.lock file', async () => { 14 | const lockFilePath = path.join(gitDirectory, 'index.lock'); 15 | await fs.writeFile(lockFilePath, 'test lock file'); 16 | 17 | const lockFiles = await checkGitLockFiles(dir); 18 | expect(lockFiles).toContain(lockFilePath); 19 | 20 | // Clean up 21 | await fs.remove(lockFilePath); 22 | }); 23 | 24 | test('detects HEAD.lock file', async () => { 25 | const lockFilePath = path.join(gitDirectory, 'HEAD.lock'); 26 | await fs.writeFile(lockFilePath, 'test lock file'); 27 | 28 | const lockFiles = await checkGitLockFiles(dir); 29 | expect(lockFiles).toContain(lockFilePath); 30 | 31 | // Clean up 32 | await fs.remove(lockFilePath); 33 | }); 34 | 35 | test('detects lock files in refs/heads', async () => { 36 | const refsHeadsDir = path.join(gitDirectory, 'refs', 'heads'); 37 | await fs.mkdirp(refsHeadsDir); 38 | const lockFilePath = path.join(refsHeadsDir, 'master.lock'); 39 | await fs.writeFile(lockFilePath, 'test lock file'); 40 | 41 | const lockFiles = await checkGitLockFiles(dir); 42 | expect(lockFiles).toContain(lockFilePath); 43 | 44 | // Clean up 45 | await fs.remove(lockFilePath); 46 | }); 47 | }); 48 | 49 | describe('removeGitLockFiles', () => { 50 | test('removes index.lock file', async () => { 51 | const lockFilePath = path.join(gitDirectory, 'index.lock'); 52 | await fs.writeFile(lockFilePath, 'test lock file'); 53 | 54 | const removedCount = await removeGitLockFiles(dir); 55 | expect(removedCount).toBe(1); 56 | expect(await fs.pathExists(lockFilePath)).toBe(false); 57 | }); 58 | 59 | test('removes multiple lock files', async () => { 60 | const indexLockPath = path.join(gitDirectory, 'index.lock'); 61 | const headLockPath = path.join(gitDirectory, 'HEAD.lock'); 62 | await fs.writeFile(indexLockPath, 'test lock file'); 63 | await fs.writeFile(headLockPath, 'test lock file'); 64 | 65 | const removedCount = await removeGitLockFiles(dir); 66 | expect(removedCount).toBe(2); 67 | expect(await fs.pathExists(indexLockPath)).toBe(false); 68 | expect(await fs.pathExists(headLockPath)).toBe(false); 69 | }); 70 | 71 | test('returns 0 when no lock files exist', async () => { 72 | const removedCount = await removeGitLockFiles(dir); 73 | expect(removedCount).toBe(0); 74 | }); 75 | 76 | test('handles missing refs directories gracefully', async () => { 77 | // This should not throw an error even if refs/heads doesn't exist 78 | const removedCount = await removeGitLockFiles(dir); 79 | expect(removedCount).toBeGreaterThanOrEqual(0); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/commitAndSync.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import { omit } from 'lodash'; 3 | import { commitAndSync, ICommitAndSyncOptions } from '../src/commitAndSync'; 4 | import { defaultGitInfo } from '../src/defaultGitInfo'; 5 | import { GitPullPushError } from '../src/errors'; 6 | import { getRemoteUrl, getSyncState, SyncState } from '../src/inspect'; 7 | import { toGitStringResult } from '../src/utils'; 8 | import { creatorGitInfo, dir, exampleToken, upstreamDir } from './constants'; 9 | import { addSomeFiles } from './utils'; 10 | 11 | describe('commitAndSync', () => { 12 | const getCommitAndSyncOptions = (): ICommitAndSyncOptions => ({ 13 | dir, 14 | remoteUrl: upstreamDir, 15 | userInfo: { ...defaultGitInfo, accessToken: exampleToken }, 16 | }); 17 | 18 | test('equal to upstream that been commitAndSync to', async () => { 19 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 20 | await addSomeFiles(); 21 | await commitAndSync(getCommitAndSyncOptions()); 22 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 23 | }); 24 | test('restore Github credential after failed', async () => { 25 | // can't push to github during test, so we use a fake token and only test failed situation 26 | const creatorRepoUrl = `https://github.com/${creatorGitInfo.gitUserName}/wiki`; 27 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 28 | await addSomeFiles(); 29 | const options = { 30 | dir, 31 | remoteUrl: creatorRepoUrl, 32 | userInfo: { ...creatorGitInfo, branch: 'main' }, 33 | }; 34 | await expect(async () => { 35 | await commitAndSync(options); 36 | }).rejects.toThrow( 37 | new GitPullPushError( 38 | // print the same error message as Error... 39 | { ...omit(options, ['remoteUrl', 'userInfo']), branch: 'main', remote: 'origin', userInfo: options.userInfo }, 40 | `remote: Invalid username or token. Password authentication is not supported for Git operations. 41 | fatal: Authentication failed for 'https://github.com/linonetwo/wiki/' 42 | `, 43 | ), 44 | ); 45 | const restoredRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 46 | expect(restoredRemoteUrl).toBe(creatorRepoUrl); 47 | }); 48 | 49 | test('sets committer identity correctly', async () => { 50 | // Add some files and commit 51 | await addSomeFiles(); 52 | await commitAndSync(getCommitAndSyncOptions()); 53 | 54 | // Verify that both author and committer are set correctly 55 | const logResult = toGitStringResult( 56 | await exec(['log', '--format=%an|%ae|%cn|%ce', '-1'], dir), 57 | ); 58 | 59 | expect(logResult.exitCode).toBe(0); 60 | const [authorName, authorEmail, committerName, committerEmail] = logResult.stdout.trim().split('|'); 61 | 62 | // Both author and committer should be set to the same user info 63 | expect(authorName).toBe(defaultGitInfo.gitUserName); 64 | expect(authorEmail).toBe(defaultGitInfo.email); 65 | expect(committerName).toBe(defaultGitInfo.gitUserName); 66 | expect(committerEmail).toBe(defaultGitInfo.email); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import fs from 'fs-extra'; 3 | import { defaultGitInfo } from '../src/defaultGitInfo'; 4 | import { initGitWithBranch } from '../src/init'; 5 | import { commitFiles, fetchRemote, mergeUpstream, pushUpstream } from '../src/sync'; 6 | import { dir, dir2, exampleImageBuffer, exampleRemoteUrl, exampleToken, upstreamDir } from './constants'; 7 | 8 | export async function addSomeFiles(location = dir): Promise { 9 | const paths: T = [`${location}/image.png`, `${location}/test.json`] as T; 10 | await fs.writeFile(paths[0], exampleImageBuffer); 11 | await fs.writeJSON(paths[1], { test: 'test' }); 12 | return paths; 13 | } 14 | 15 | export async function addAnUpstream(repoPath = dir): Promise { 16 | await exec(['remote', 'add', defaultGitInfo.remote, upstreamDir], repoPath); 17 | /** 18 | * Need to fetch the remote repo first, otherwise it will say: 19 | * 20 | * ``` 21 | * % git rev-list --count --left-right origin/main...HEAD 22 | fatal: ambiguous argument 'origin/main...HEAD': unknown revision or path not in the working tree. 23 | Use '--' to separate paths from revisions, like this: 24 | 'git [...] -- [...]' 25 | * ``` 26 | */ 27 | await fetchRemote(repoPath, defaultGitInfo.remote, defaultGitInfo.branch); 28 | } 29 | 30 | export async function addHTTPRemote(remoteName = defaultGitInfo.remote, remoteUrl = exampleRemoteUrl, directory = dir): Promise { 31 | await exec(['remote', 'add', remoteName, remoteUrl], directory); 32 | await exec(['remote', 'set-url', remoteName, remoteUrl], directory); 33 | } 34 | 35 | export async function addAndCommitUsingDugite( 36 | location = dir, 37 | runBetween: () => void | Promise = () => {}, 38 | message = 'some commit message', 39 | ): Promise { 40 | await exec(['add', '.'], location); 41 | await runBetween(); 42 | await exec(['commit', '-m', message, `--author="${defaultGitInfo.gitUserName} <${defaultGitInfo.email}>"`], location); 43 | } 44 | 45 | export async function createAndSyncRepo2ToRemote(): Promise { 46 | await fs.mkdirp(dir2); 47 | await initGitWithBranch(dir2, defaultGitInfo.branch, { initialCommit: false, bare: false, gitUserName: defaultGitInfo.gitUserName, email: defaultGitInfo.email }); 48 | await addAnUpstream(dir2); 49 | } 50 | 51 | /** 52 | * Simulate another repo push to upstream, letting our local repo being behind. 53 | * Have to run `createAndSyncRepo2ToRemote()` before this. 54 | */ 55 | export async function anotherRepo2PushSomeFiles() { 56 | await fetchRemote(dir2, defaultGitInfo.remote); 57 | try { 58 | // this can fail if dir1 never push its initial commit to the remote, so remote is still bare and can't be pull. It is OK to ignore this error. 59 | await mergeUpstream(dir2, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 60 | } catch (error) { 61 | // Ignore merge failure when the upstream is still bare; expected during setup. 62 | void error; 63 | } 64 | await addSomeFiles(dir2); 65 | await commitFiles(dir2, defaultGitInfo.gitUserName, defaultGitInfo.email); 66 | await pushUpstream(dir2, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 67 | } 68 | -------------------------------------------------------------------------------- /src/initGit.ts: -------------------------------------------------------------------------------- 1 | import { truncate } from 'lodash'; 2 | import { commitAndSync } from './commitAndSync'; 3 | import { defaultGitInfo as defaultDefaultGitInfo } from './defaultGitInfo'; 4 | import { SyncParameterMissingError } from './errors'; 5 | import { initGitWithBranch } from './init'; 6 | import { GitStep, IGitUserInfos, IGitUserInfosWithoutToken, ILogger } from './interface'; 7 | import { commitFiles } from './sync'; 8 | 9 | export type IInitGitOptions = IInitGitOptionsSyncImmediately | IInitGitOptionsNotSync; 10 | export interface IInitGitOptionsSyncImmediately { 11 | /** Optional fallback of userInfo. If some info is missing in userInfo, will use defaultGitInfo instead. */ 12 | defaultGitInfo?: typeof defaultDefaultGitInfo; 13 | /** wiki folder path, can be relative */ 14 | dir: string; 15 | logger?: ILogger; 16 | /** only required if syncImmediately is true, the storage service url we are sync to, for example your github repo url */ 17 | remoteUrl: string; 18 | /** should we sync after git init? */ 19 | syncImmediately: true; 20 | /** user info used in the commit message */ 21 | userInfo: IGitUserInfos; 22 | } 23 | export interface IInitGitOptionsNotSync { 24 | defaultGitInfo?: typeof defaultDefaultGitInfo; 25 | /** wiki folder path, can be relative */ 26 | dir: string; 27 | logger?: ILogger; 28 | /** should we sync after git init? */ 29 | syncImmediately?: false; 30 | userInfo?: IGitUserInfosWithoutToken | IGitUserInfos; 31 | } 32 | 33 | export async function initGit(options: IInitGitOptions): Promise { 34 | const { dir, userInfo, syncImmediately, logger, defaultGitInfo = defaultDefaultGitInfo } = options; 35 | 36 | const logProgress = (step: GitStep): unknown => 37 | logger?.info(step, { 38 | functionName: 'initGit', 39 | step, 40 | }); 41 | const logDebug = (message: string, step: GitStep): unknown => logger?.debug(message, { functionName: 'initGit', step }); 42 | 43 | logProgress(GitStep.StartGitInitialization); 44 | const { gitUserName, email, branch } = userInfo ?? defaultGitInfo; 45 | logDebug(`Running git init in dir ${dir}`, GitStep.StartGitInitialization); 46 | await initGitWithBranch(dir, branch, { gitUserName, email: email ?? defaultGitInfo.email }); 47 | logDebug(`Succefully Running git init in dir ${dir}`, GitStep.StartGitInitialization); 48 | await commitFiles(dir, gitUserName, email ?? defaultGitInfo.email, 'Initial Commit with Git-Sync-JS', [], logger); 49 | 50 | // if we are config local note git, we are done here 51 | if (syncImmediately !== true) { 52 | logProgress(GitStep.GitRepositoryConfigurationFinished); 53 | return; 54 | } 55 | // sync to remote, start config synced note 56 | if (userInfo === undefined || !('accessToken' in userInfo) || userInfo?.accessToken?.length === 0) { 57 | throw new SyncParameterMissingError('accessToken'); 58 | } 59 | const { remoteUrl } = options; 60 | if (remoteUrl === undefined || remoteUrl.length === 0) { 61 | throw new SyncParameterMissingError('remoteUrl'); 62 | } 63 | logDebug( 64 | `Calling commitAndSync() from initGit() Using gitUrl ${remoteUrl} with gitUserName ${gitUserName} and accessToken ${ 65 | truncate(userInfo?.accessToken, { 66 | length: 24, 67 | }) 68 | }`, 69 | GitStep.StartConfiguringGithubRemoteRepository, 70 | ); 71 | logProgress(GitStep.StartConfiguringGithubRemoteRepository); 72 | await commitAndSync(options); 73 | } 74 | -------------------------------------------------------------------------------- /test/forcePull.test.ts: -------------------------------------------------------------------------------- 1 | import { commitFiles, fetchRemote, forcePull, getSyncState, IForcePullOptions, SyncState } from '../src'; 2 | import { defaultGitInfo } from '../src/defaultGitInfo'; 3 | import { dir, exampleToken, upstreamDir } from './constants'; 4 | import { addAnUpstream, addSomeFiles, anotherRepo2PushSomeFiles, createAndSyncRepo2ToRemote } from './utils'; 5 | 6 | describe('forcePull', () => { 7 | beforeEach(async () => { 8 | await addAnUpstream(); 9 | // repo2 modify the remote, make us behind 10 | await createAndSyncRepo2ToRemote(); 11 | await anotherRepo2PushSomeFiles(); 12 | }); 13 | 14 | const getForcePullOptions = (): IForcePullOptions => ({ 15 | dir, 16 | remoteUrl: upstreamDir, 17 | userInfo: { ...defaultGitInfo, accessToken: exampleToken }, 18 | }); 19 | 20 | test('added files will be diverged', async () => { 21 | await addSomeFiles(); 22 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email); 23 | await fetchRemote(dir, defaultGitInfo.remote, defaultGitInfo.branch); 24 | 25 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('diverged'); 26 | }); 27 | test('added files discarded after pull and being equal', async () => { 28 | await addSomeFiles(); 29 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email); 30 | // force pull without fetch 31 | await forcePull(getForcePullOptions()); 32 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 33 | }); 34 | 35 | test('fetches latest changes before checking sync state', async () => { 36 | // This test verifies that forcePull fetches the latest remote state 37 | // before checking if local is equal to remote 38 | 39 | // First fetch to know the remote state 40 | await fetchRemote(dir, defaultGitInfo.remote, defaultGitInfo.branch); 41 | 42 | // Check initial state (could be behind or diverged depending on local commits) 43 | const initialSyncState = await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote); 44 | expect(['behind', 'diverged']).toContain(initialSyncState); 45 | 46 | // forcePull should fetch the latest and then reset 47 | await forcePull(getForcePullOptions()); 48 | 49 | // After forcePull, should be equal to remote 50 | const finalSyncState = await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote); 51 | expect(finalSyncState).toBe('equal'); 52 | }); 53 | 54 | test('forcePull uses correct remote and branch names (issue #515)', async () => { 55 | // This test verifies the fix for issue #515: 56 | // forcePull should use the actual remoteName and defaultBranchName 57 | // instead of defaultGitInfo.remote and defaultGitInfo.branch 58 | 59 | // repo2 has pushed changes, so we should be behind after fetch 60 | await fetchRemote(dir, defaultGitInfo.remote, defaultGitInfo.branch); 61 | const syncStateBeforePull = await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote); 62 | 63 | // Should not be equal if repo2 pushed changes 64 | expect(syncStateBeforePull).not.toBe('equal'); 65 | 66 | // forcePull should correctly fetch and reset 67 | await forcePull(getForcePullOptions()); 68 | 69 | // After forcePull, should definitely be equal 70 | const syncStateAfterPull = await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote); 71 | expect(syncStateAfterPull).toBe('equal'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/constants.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { IGitUserInfos } from '../src/interface'; 3 | 4 | /** 5 | * Random dir name to prevent parallel test execution collision 6 | */ 7 | let repoName = Math.random().toString(); 8 | /** 9 | * mockRepoLocation 10 | */ 11 | export let dir: string; 12 | /** 13 | * Another location to simulate you have two places sync to one upstream repo (Github). 14 | * Not all test use this, so handle this in each test. 15 | */ 16 | export let dir2: string; 17 | export let upstreamDir: string; 18 | 19 | export let gitDirectory: string; 20 | export let upstreamDirGitDirectory: string; 21 | export let gitSyncRepoDirectory: string; 22 | export let gitSyncRepoDirectoryGitDirectory: string; 23 | 24 | export const creatorGitInfo: IGitUserInfos & { remote: string } = { 25 | email: 'gitsync@gmail.com', 26 | gitUserName: 'linonetwo', 27 | branch: 'master', 28 | remote: 'origin', 29 | accessToken: 'ghp_zA8Xet3mupV6kWj2sFsKUpTv45hJA6ZJyzY6', 30 | }; 31 | 32 | /** 33 | * use currentTestName to get better constants, should call in jest functions as early as possible 34 | */ 35 | export const setGlobalConstants = (): void => { 36 | /** 37 | * Random dir name to prevent parallel test execution collision 38 | * This is undefined in describe! Only work inside test block. 39 | */ 40 | repoName = expect.getState().currentTestName!; 41 | /** 42 | * mockRepoLocation 43 | */ 44 | dir = path.join(__dirname, 'mockRepo', repoName); 45 | dir2 = path.join(__dirname, 'mockRepo2', repoName); 46 | upstreamDir = path.join(__dirname, 'mockUpstreamRepo', repoName); 47 | 48 | gitDirectory = path.join(dir, '.git'); 49 | upstreamDirGitDirectory = path.join(upstreamDir, '.git'); 50 | gitSyncRepoDirectory = path.join(__dirname, '..'); 51 | gitSyncRepoDirectoryGitDirectory = path.join(gitSyncRepoDirectory, '.git'); 52 | }; 53 | 54 | export const exampleRepoName = 'tiddlygit-test/wiki'; 55 | /** 56 | * In TidGi, we use https remote without `.git` suffix, we will add `.git` when we need it. 57 | */ 58 | export const exampleRemoteUrl = `https://github.com/${exampleRepoName}`; 59 | export const exampleToken = 'testToken'; 60 | 61 | /** from https://stackoverflow.com/questions/39062595/how-can-i-create-a-png-blob-from-binary-data-in-a-typed-array */ 62 | export const exampleImageBuffer = Buffer.from( 63 | new Uint8Array([ 64 | 137, 65 | 80, 66 | 78, 67 | 71, 68 | 13, 69 | 10, 70 | 26, 71 | 10, 72 | 0, 73 | 0, 74 | 0, 75 | 13, 76 | 73, 77 | 72, 78 | 68, 79 | 82, 80 | 0, 81 | 0, 82 | 0, 83 | 8, 84 | 0, 85 | 0, 86 | 0, 87 | 8, 88 | 8, 89 | 2, 90 | 0, 91 | 0, 92 | 0, 93 | 75, 94 | 109, 95 | 41, 96 | 220, 97 | 0, 98 | 0, 99 | 0, 100 | 34, 101 | 73, 102 | 68, 103 | 65, 104 | 84, 105 | 8, 106 | 215, 107 | 99, 108 | 120, 109 | 173, 110 | 168, 111 | 135, 112 | 21, 113 | 49, 114 | 0, 115 | 241, 116 | 255, 117 | 15, 118 | 90, 119 | 104, 120 | 8, 121 | 33, 122 | 129, 123 | 83, 124 | 7, 125 | 97, 126 | 163, 127 | 136, 128 | 214, 129 | 129, 130 | 93, 131 | 2, 132 | 43, 133 | 2, 134 | 0, 135 | 181, 136 | 31, 137 | 90, 138 | 179, 139 | 225, 140 | 252, 141 | 176, 142 | 37, 143 | 0, 144 | 0, 145 | 0, 146 | 0, 147 | 73, 148 | 69, 149 | 78, 150 | 68, 151 | 174, 152 | 66, 153 | 96, 154 | 130, 155 | ]), 156 | ); 157 | -------------------------------------------------------------------------------- /test/sync.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { defaultGitInfo } from '../src/defaultGitInfo'; 4 | import { AssumeSyncError } from '../src/errors'; 5 | import { assumeSync, getModifiedFileList, getSyncState, SyncState } from '../src/inspect'; 6 | import { commitFiles, fetchRemote, mergeUpstream, pushUpstream } from '../src/sync'; 7 | import { dir, exampleToken } from './constants'; 8 | import { addAnUpstream, addSomeFiles, anotherRepo2PushSomeFiles, createAndSyncRepo2ToRemote } from './utils'; 9 | 10 | describe('commitFiles', () => { 11 | describe('with upstream', () => { 12 | beforeEach(async () => { 13 | await addAnUpstream(); 14 | }); 15 | 16 | test('not change sync state between upstream', async () => { 17 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 18 | await addSomeFiles(); 19 | const sharedCommitMessage = 'some commit message'; 20 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email, sharedCommitMessage); 21 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 22 | await expect(async () => { 23 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 24 | }).rejects.toThrow(new AssumeSyncError('noUpstreamOrBareUpstream')); 25 | }); 26 | }); 27 | 28 | test('ignore provided file list', async () => { 29 | await addSomeFiles(); 30 | const ignoredFileName = '.useless'; 31 | const ignoredFilePath = path.join(dir, ignoredFileName); 32 | await fs.writeFile(ignoredFilePath, 'useless'); 33 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email, undefined, [ignoredFileName]); 34 | const fileList = await getModifiedFileList(dir); 35 | expect(fileList).toStrictEqual([{ filePath: ignoredFilePath, fileRelativePath: ignoredFileName, type: '??' }]); 36 | }); 37 | }); 38 | 39 | describe('pushUpstream', () => { 40 | beforeEach(async () => { 41 | await addAnUpstream(); 42 | }); 43 | test('equal to upstream after push', async () => { 44 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 45 | await addSomeFiles(); 46 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email); 47 | 48 | await pushUpstream(dir, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 49 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 50 | }); 51 | }); 52 | 53 | describe('mergeUpstream', () => { 54 | beforeEach(async () => { 55 | await Promise.all([ 56 | addAnUpstream(), 57 | createAndSyncRepo2ToRemote(), 58 | ]); 59 | }); 60 | 61 | test('equal to upstream after pull', async () => { 62 | // local repo with init commit is diverged with upstream with init commit 63 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 64 | await pushUpstream(dir, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 65 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 66 | 67 | await anotherRepo2PushSomeFiles(); 68 | 69 | await fetchRemote(dir, defaultGitInfo.remote); 70 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('behind'); 71 | await mergeUpstream(dir, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 72 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@docusaurus/types'; 2 | 3 | import { themes } from 'prism-react-renderer'; 4 | 5 | const organizationName = 'tiddly-gittly'; 6 | const projectName = 'git-sync-js'; 7 | 8 | export default { 9 | title: 'Git-Sync-JS Docs', 10 | tagline: 'API and usage or git-sync-js npm package.', 11 | url: `https://${organizationName}.github.io`, 12 | baseUrl: `/${projectName}/`, 13 | onBrokenLinks: 'throw', 14 | onBrokenMarkdownLinks: 'throw', 15 | favicon: 'favicon.ico', 16 | // GitHub Pages adds a trailing slash by default that I don't want 17 | trailingSlash: false, 18 | 19 | // GitHub pages deployment config. 20 | // If you aren't using GitHub pages, you don't need these. 21 | organizationName, // Usually your GitHub org/user name. 22 | projectName, // Usually your repo name. 23 | 24 | // Even if you don't use internalization, you can use this field to set useful 25 | // metadata like html lang. For example, if your site is Chinese, you may want 26 | // to replace "en" with "zh-Hans". 27 | i18n: { 28 | defaultLocale: 'en', 29 | locales: ['en'], 30 | }, 31 | 32 | presets: [ 33 | [ 34 | 'classic', 35 | /** @type {import('@docusaurus/preset-classic').Options} */ 36 | ({ 37 | docs: { 38 | // sidebarPath: require.resolve("./sidebars.js"), 39 | // Please change this to your repo. 40 | // Remove this to remove the "edit this page" links. 41 | editUrl: `https://github.com/${organizationName}/${projectName}/tree/master/docs/`, 42 | routeBasePath: '/', 43 | }, 44 | theme: { 45 | // customCss: require.resolve("./src/css/custom.css"), 46 | }, 47 | }), 48 | ], 49 | ], 50 | 51 | themeConfig: 52 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 53 | ({ 54 | navbar: { 55 | title: 'Git-Sync-JS Docs', 56 | logo: { 57 | alt: 'Logo', 58 | // temporary logo, change this when we have a real one 59 | // it will try to load `static/images/Logo.png` if provided `"/images/Logo.png"`. 60 | src: '/images/Logo.webp', 61 | }, 62 | items: [ 63 | { to: 'api', label: 'API', position: 'left' }, 64 | { 65 | href: `https://github.com/${organizationName}/${projectName}`, 66 | label: 'GitHub', 67 | position: 'right', 68 | }, 69 | ], 70 | }, 71 | footer: { 72 | style: 'dark', 73 | links: [ 74 | { 75 | title: 'Community', 76 | items: [ 77 | { 78 | label: 'Stack Overflow', 79 | href: 'https://stackoverflow.com/questions/tagged/git-sync-js', 80 | }, 81 | { 82 | label: 'Discord', 83 | href: 'https://discordapp.com/invite/git-sync-js', 84 | }, 85 | { 86 | label: 'Twitter', 87 | href: 'https://twitter.com/git-sync-js', 88 | }, 89 | ], 90 | }, 91 | { 92 | title: 'More', 93 | items: [ 94 | { 95 | label: 'API', 96 | to: 'api', 97 | }, 98 | { 99 | label: 'GitHub', 100 | href: `https://github.com/${organizationName}/${projectName}`, 101 | }, 102 | ], 103 | }, 104 | ], 105 | copyright: `Copyright © ${new Date().getFullYear()} ${organizationName}. Doc site built with Docusaurus.`, 106 | }, 107 | prism: { 108 | theme: themes.vsLight, 109 | darkTheme: themes.vsDark, 110 | }, 111 | }), 112 | } satisfies Config; 113 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | export interface IGitUserInfos extends IGitUserInfosWithoutToken { 2 | /** Github Login: token */ 3 | accessToken: string; 4 | } 5 | 6 | export interface IGitUserInfosWithoutToken { 7 | branch: string; 8 | /** Git commit message email */ 9 | email: string | null | undefined; 10 | /** Github Login: username , this is also used to filter user's repo when searching repo */ 11 | gitUserName: string; 12 | } 13 | 14 | /** custom logger to report progress on each step 15 | * we don't use logger to report error, we throw errors. 16 | */ 17 | export interface ILogger { 18 | /** used to report debug logs */ 19 | debug: (message: string, context: ILoggerContext) => unknown; 20 | /** used to report progress for human user to read */ 21 | info: (message: GitStep, context: ILoggerContext) => unknown; 22 | /** used to report failed optional progress */ 23 | warn: (message: string, context: ILoggerContext) => unknown; 24 | } 25 | /** context to tell logger which function we are in */ 26 | export interface ILoggerContext { 27 | branch?: string; 28 | dir?: string; 29 | functionName: string; 30 | remoteUrl?: string; 31 | step: GitStep; 32 | } 33 | 34 | export enum GitStep { 35 | AddComplete = 'AddComplete', 36 | AddingFiles = 'AddingFiles', 37 | CantSyncInSpecialGitStateAutoFixSucceed = 'CantSyncInSpecialGitStateAutoFixSucceed', 38 | CheckingLocalGitRepoSanity = 'CheckingLocalGitRepoSanity', 39 | CheckingLocalSyncState = 'CheckingLocalSyncState', 40 | CommitComplete = 'CommitComplete', 41 | FetchingData = 'FetchingData', 42 | FinishForcePull = 'FinishForcePull', 43 | GitMerge = 'GitMerge', 44 | GitMergeComplete = 'GitMergeComplete', 45 | GitMergeFailed = 'GitMergeFailed', 46 | GitPush = 'GitPush', 47 | GitPushComplete = 'GitPushComplete', 48 | GitPushFailed = 'GitPushFailed', 49 | GitRepositoryConfigurationFinished = 'GitRepositoryConfigurationFinished', 50 | HaveThingsToCommit = 'HaveThingsToCommit', 51 | LocalAheadStartUpload = 'LocalAheadStartUpload', 52 | LocalStateBehindSync = 'LocalStateBehindSync', 53 | LocalStateDivergeRebase = 'LocalStateDivergeRebase', 54 | NoNeedToSync = 'NoNeedToSync', 55 | NoUpstreamCantPush = 'NoUpstreamCantPush', 56 | PerformLastCheckBeforeSynchronizationFinish = 'PerformLastCheckBeforeSynchronizationFinish', 57 | PrepareCloneOnlineWiki = 'PrepareCloneOnlineWiki', 58 | PrepareSync = 'PrepareSync', 59 | PreparingUserInfo = 'PreparingUserInfo', 60 | RebaseConflictNeedsResolve = 'RebaseConflictNeedsResolve', 61 | RebaseResultChecking = 'RebaseResultChecking', 62 | RebaseSucceed = 'RebaseSucceed', 63 | SkipForcePull = 'SkipForcePull', 64 | StartBackupToGitRemote = 'StartBackupToGitRemote', 65 | StartConfiguringGithubRemoteRepository = 'StartConfiguringGithubRemoteRepository', 66 | StartFetchingFromGithubRemote = 'StartFetchingFromGithubRemote', 67 | StartForcePull = 'StartForcePull', 68 | StartGitInitialization = 'StartGitInitialization', 69 | StartResettingLocalToRemote = 'StartResettingLocalToRemote', 70 | /** this means our algorithm have some problems */ 71 | SyncFailedAlgorithmWrong = 'SyncFailedAlgorithmWrong', 72 | SynchronizationFinish = 'SynchronizationFinish', 73 | } 74 | /** 75 | * Steps that indicate we have new files, so we can restart our wiki to reload changes. 76 | * 77 | * @example

 78 |  * // (inside a promise)
 79 |  * let hasChanges = false;
 80 |     observable?.subscribe({
 81 |       next: (messageObject) => {
 82 |         if (messageObject.level === 'error') {
 83 |           return;
 84 |         }
 85 |         const { meta } = messageObject;
 86 |         if (typeof meta === 'object' && meta !== null && 'step' in meta && stepsAboutChange.includes((meta as { step: GitStep }).step)) {
 87 |           hasChanges = true;
 88 |         }
 89 |       },
 90 |       complete: () => {
 91 |         resolve(hasChanges);
 92 |       },
 93 |     });
 94 |   
95 | */ 96 | export const stepsAboutChange = [ 97 | GitStep.GitMergeComplete, 98 | GitStep.RebaseSucceed, 99 | GitStep.FinishForcePull, 100 | ]; 101 | -------------------------------------------------------------------------------- /src/forcePull.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import { syncPreflightCheck } from './commitAndSync'; 3 | import { credentialOff, credentialOn } from './credential'; 4 | import { defaultGitInfo as defaultDefaultGitInfo } from './defaultGitInfo'; 5 | import { CantForcePullError, SyncParameterMissingError } from './errors'; 6 | import { getDefaultBranchName, getRemoteName, getSyncState } from './inspect'; 7 | import { GitStep, IGitUserInfos, ILogger } from './interface'; 8 | import { fetchRemote } from './sync'; 9 | import { toGitStringResult } from './utils'; 10 | 11 | export interface IForcePullOptions { 12 | /** Optional fallback of userInfo. If some info is missing in userInfo, will use defaultGitInfo instead. */ 13 | defaultGitInfo?: typeof defaultDefaultGitInfo; 14 | /** wiki folder path, can be relative */ 15 | dir: string; 16 | logger?: ILogger; 17 | /** the storage service url we are sync to, for example your github repo url 18 | * When empty, and commitOnly===true, it means we just want commit, without sync 19 | */ 20 | remoteUrl?: string; 21 | /** user info used in the commit message 22 | * When empty, and commitOnly===true, it means we just want commit, without sync 23 | */ 24 | userInfo?: IGitUserInfos; 25 | } 26 | 27 | /** 28 | * Ignore all local changes, force reset local to remote. 29 | * This is usually used in readonly blog, that will fetch content from a remote repo. And you can push content to the remote repo, let the blog update. 30 | */ 31 | export async function forcePull(options: IForcePullOptions) { 32 | const { dir, logger, defaultGitInfo = defaultDefaultGitInfo, userInfo, remoteUrl } = options; 33 | const { gitUserName, branch } = userInfo ?? defaultGitInfo; 34 | const { accessToken } = userInfo ?? {}; 35 | const defaultBranchName = (await getDefaultBranchName(dir)) ?? branch; 36 | const remoteName = await getRemoteName(dir, branch); 37 | 38 | if (accessToken === '' || accessToken === undefined) { 39 | throw new SyncParameterMissingError('accessToken'); 40 | } 41 | if (remoteUrl === '' || remoteUrl === undefined) { 42 | throw new SyncParameterMissingError('remoteUrl'); 43 | } 44 | 45 | const logProgress = (step: GitStep): unknown => 46 | logger?.info(step, { 47 | functionName: 'forcePull', 48 | step, 49 | dir, 50 | remoteUrl, 51 | branch: defaultBranchName, 52 | }); 53 | const logDebug = (message: string, step: GitStep): unknown => 54 | logger?.debug(message, { 55 | functionName: 'forcePull', 56 | step, 57 | dir, 58 | remoteUrl, 59 | branch: defaultBranchName, 60 | }); 61 | 62 | logProgress(GitStep.StartForcePull); 63 | logDebug(`Do preflight Check before force pull in dir ${dir}`, GitStep.StartForcePull); 64 | // preflight check 65 | await syncPreflightCheck({ 66 | dir, 67 | logger, 68 | logProgress, 69 | logDebug, 70 | defaultGitInfo, 71 | userInfo, 72 | }); 73 | logProgress(GitStep.StartConfiguringGithubRemoteRepository); 74 | await credentialOn(dir, remoteUrl, gitUserName, accessToken, remoteName); 75 | try { 76 | logProgress(GitStep.StartFetchingFromGithubRemote); 77 | logDebug(`Fetching from remote ${remoteName} branch ${defaultBranchName}`, GitStep.StartFetchingFromGithubRemote); 78 | await fetchRemote(dir, remoteName, defaultBranchName, logger); 79 | const syncState = await getSyncState(dir, defaultBranchName, remoteName, logger); 80 | logDebug(`syncState in dir ${dir} is ${syncState}`, GitStep.StartFetchingFromGithubRemote); 81 | if (syncState === 'equal') { 82 | // if there is no new commit in remote (and nothing messy in local), we don't need to pull. 83 | logProgress(GitStep.SkipForcePull); 84 | return; 85 | } 86 | logProgress(GitStep.StartResettingLocalToRemote); 87 | await hardResetLocalToRemote(dir, defaultBranchName, remoteName); 88 | logProgress(GitStep.FinishForcePull); 89 | } catch (error) { 90 | if (error instanceof CantForcePullError) { 91 | throw error; 92 | } else { 93 | throw new CantForcePullError(`${(error as Error).message} ${(error as Error).stack ?? ''}`); 94 | } 95 | } finally { 96 | await credentialOff(dir, remoteName, remoteUrl); 97 | } 98 | } 99 | 100 | /** 101 | * Internal method used by forcePull, does the `reset --hard`. 102 | */ 103 | export async function hardResetLocalToRemote(dir: string, branch: string, remoteName: string) { 104 | const { exitCode, stderr } = toGitStringResult(await exec(['reset', '--hard', `${remoteName}/${branch}`], dir)); 105 | if (exitCode !== 0) { 106 | throw new CantForcePullError(`${remoteName}/${branch} ${stderr}`); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom errors, for user to catch and `instanceof`. So you can show your custom translated message for each error type. 3 | * `Object.setPrototypeOf(this, AssumeSyncError.prototype);` to fix https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work 4 | */ 5 | import { truncate } from 'lodash'; 6 | import { SyncState } from './inspect'; 7 | import { IGitUserInfos, IGitUserInfosWithoutToken } from './interface'; 8 | 9 | export class AssumeSyncError extends Error { 10 | constructor(state: SyncState, extraMessage?: string) { 11 | super(extraMessage); 12 | Object.setPrototypeOf(this, AssumeSyncError.prototype); 13 | this.name = 'AssumeSyncError'; 14 | this.message = `E-1 In this state, git should have been sync with the remote, but it is "${state}", this is caused by procedural bug in the git-sync-js. ${extraMessage ?? ''}`; 15 | } 16 | } 17 | export class SyncParameterMissingError extends Error { 18 | /** the missing parameterName */ 19 | parameterName: string; 20 | constructor(parameterName = 'accessToken') { 21 | super(parameterName); 22 | Object.setPrototypeOf(this, SyncParameterMissingError.prototype); 23 | this.name = 'SyncParameterMissingError'; 24 | this.parameterName = parameterName; 25 | this.message = `E-2 We need ${parameterName} to sync to the cloud, you should pass ${parameterName} as parameters in options.`; 26 | } 27 | } 28 | 29 | export class GitPullPushError extends Error { 30 | constructor( 31 | configuration: { 32 | branch?: string; 33 | /** wiki folder path, can be relative */ 34 | dir: string; 35 | /** for example, origin */ 36 | remote?: string; 37 | /** the storage service url we are sync to, for example your github repo url */ 38 | remoteUrl?: string; 39 | /** user info used in the commit message */ 40 | userInfo?: IGitUserInfos | IGitUserInfosWithoutToken; 41 | }, 42 | extraMessages: string, 43 | ) { 44 | super(extraMessages); 45 | Object.setPrototypeOf(this, GitPullPushError.prototype); 46 | this.name = 'GitPullPushError'; 47 | this.message = `E-3 failed to config git to successfully pull from or push to remote with configuration ${ 48 | JSON.stringify({ 49 | ...configuration, 50 | userInfo: { 51 | ...configuration.userInfo, 52 | accessToken: truncate((configuration?.userInfo as IGitUserInfos)?.accessToken, { 53 | length: 24, 54 | }), 55 | }, 56 | }) 57 | }.\nerrorMessages: ${extraMessages}`; 58 | } 59 | } 60 | 61 | export class CantSyncGitNotInitializedError extends Error { 62 | /** the directory that should have a git repo */ 63 | directory: string; 64 | constructor(directory: string) { 65 | super(directory); 66 | Object.setPrototypeOf(this, CantSyncGitNotInitializedError.prototype); 67 | this.directory = directory; 68 | this.name = 'CantSyncGitNotInitializedError'; 69 | this.message = `E-4 we can't sync on a git repository that is not initialized, maybe this folder is not a git repository. ${directory}`; 70 | } 71 | } 72 | 73 | export class SyncScriptIsInDeadLoopError extends Error { 74 | constructor() { 75 | super(); 76 | Object.setPrototypeOf(this, SyncScriptIsInDeadLoopError.prototype); 77 | this.name = 'SyncScriptIsInDeadLoopError'; 78 | this.message = `E-5 Unable to sync, and Sync script is in a dead loop, this is caused by procedural bug in the git-sync-js.`; 79 | } 80 | } 81 | 82 | export class CantSyncInSpecialGitStateAutoFixFailed extends Error { 83 | stateMessage: string; 84 | constructor(stateMessage: string) { 85 | super(stateMessage); 86 | Object.setPrototypeOf(this, CantSyncInSpecialGitStateAutoFixFailed.prototype); 87 | this.stateMessage = stateMessage; 88 | this.name = 'CantSyncInSpecialGitStateAutoFixFailed'; 89 | this.message = 90 | `E-6 Unable to Sync, this folder is in special condition, thus can't Sync directly. An auto-fix has been tried, but error still remains. Please resolve all the conflict manually (For example, use VSCode to open the wiki folder), if this still don't work out, please use professional Git tools (Source Tree, GitKraken) to solve this. This is caused by procedural bug in the git-sync-js.\n${stateMessage}`; 91 | } 92 | } 93 | 94 | export class CantForcePullError extends Error { 95 | stateMessage: string; 96 | constructor(stateMessage: string) { 97 | super(stateMessage); 98 | Object.setPrototypeOf(this, CantForcePullError.prototype); 99 | this.stateMessage = stateMessage; 100 | this.name = 'CantForcePullError'; 101 | this.message = `E-7 Unable to force pull remote. This is caused by procedural bug in the git-sync-js.\n${stateMessage}`; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/initGit.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import { defaultGitInfo } from '../src/defaultGitInfo'; 3 | import { AssumeSyncError } from '../src/errors'; 4 | import { initGitWithBranch } from '../src/init'; 5 | import { initGit } from '../src/initGit'; 6 | import { assumeSync, getDefaultBranchName, getSyncState, hasGit, haveLocalChanges, SyncState } from '../src/inspect'; 7 | import { commitFiles, fetchRemote, pushUpstream } from '../src/sync'; 8 | import { dir, exampleToken, gitDirectory, upstreamDir } from './constants'; 9 | import { addAndCommitUsingDugite, addAnUpstream, addSomeFiles } from './utils'; 10 | 11 | describe('initGit', () => { 12 | beforeEach(async () => { 13 | await Promise.all([ 14 | // remove dir's .git folder in this test suit, so we have a clean folder to init 15 | fs.remove(gitDirectory), 16 | // remove remote created by test/jest.setup.ts, and create a bare repo. Because init on two repo will cause them being diverged. 17 | fs.remove(upstreamDir), 18 | ]); 19 | await fs.mkdirp(upstreamDir); 20 | await initGitWithBranch(upstreamDir, defaultGitInfo.branch, { initialCommit: false, bare: true, gitUserName: defaultGitInfo.gitUserName, email: defaultGitInfo.email }); 21 | }); 22 | 23 | const testBranchName = 'test-branch'; 24 | test('Have a valid local repo after init', async () => { 25 | await initGit({ dir, syncImmediately: false, userInfo: { ...defaultGitInfo, branch: testBranchName } }); 26 | expect(await hasGit(dir)).toBe(true); 27 | expect(await haveLocalChanges(dir)).toBe(false); 28 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 29 | expect(await getDefaultBranchName(dir)).toBe(testBranchName); 30 | }); 31 | 32 | test('Fallback to default info', async () => { 33 | await initGit({ dir, syncImmediately: false, defaultGitInfo: { ...defaultGitInfo, branch: testBranchName } }); 34 | expect(await getDefaultBranchName(dir)).toBe(testBranchName); 35 | }); 36 | 37 | test("Don't use fallback if have provided info", async () => { 38 | await initGit({ 39 | dir, 40 | syncImmediately: false, 41 | userInfo: { ...defaultGitInfo, branch: testBranchName }, 42 | defaultGitInfo: { ...defaultGitInfo, branch: testBranchName + '-bad' }, 43 | }); 44 | expect(await getDefaultBranchName(dir)).toBe(testBranchName); 45 | }); 46 | 47 | describe('with upstream', () => { 48 | test('equal to upstream that using dugite add', async () => { 49 | await initGit({ 50 | dir, 51 | syncImmediately: false, 52 | defaultGitInfo, 53 | }); 54 | // nested describe > beforeEach execute first, so after we add upstream, the .git folder is deleted and recreated, we need to manually fetch here 55 | await fetchRemote(dir, defaultGitInfo.remote, defaultGitInfo.branch); 56 | // basically same as other test suit 57 | 58 | // syncImmediately: false, so we don't have a remote yet 59 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 60 | await addAnUpstream(); 61 | // upstream is bare (no commit), so it is still noUpstreamOrBareUpstream 62 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 63 | 64 | const sharedCommitMessage = 'some commit message'; 65 | await addSomeFiles(); 66 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email, sharedCommitMessage); 67 | // still BareUpstream here 68 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 69 | await expect(async () => { 70 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 71 | }).rejects.toThrow(new AssumeSyncError('noUpstreamOrBareUpstream')); 72 | 73 | await pushUpstream(dir, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 74 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 75 | }); 76 | 77 | test('syncImmediately to get equal state', async () => { 78 | await initGit({ 79 | dir, 80 | syncImmediately: true, 81 | remoteUrl: upstreamDir, 82 | userInfo: { ...defaultGitInfo, accessToken: exampleToken }, 83 | }); 84 | // basically same as other test suit 85 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 86 | await addSomeFiles(); 87 | const sharedCommitMessage = 'some commit message'; 88 | await commitFiles(dir, defaultGitInfo.gitUserName, defaultGitInfo.email, sharedCommitMessage); 89 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('ahead'); 90 | await expect(async () => { 91 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 92 | }).rejects.toThrow(new AssumeSyncError('ahead')); 93 | 94 | // modify upstream 95 | await addSomeFiles(upstreamDir); 96 | await addAndCommitUsingDugite(upstreamDir, () => {}, sharedCommitMessage); 97 | // it is ahead until we push the latest remote 98 | await fetchRemote(dir, defaultGitInfo.remote); 99 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('ahead'); 100 | await pushUpstream(dir, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 101 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-sync-js 2 | 3 | [Documentation Site](https://tiddly-gittly.github.io/git-sync-js/) 4 | 5 | JS implementation for [Git-Sync](https://github.com/simonthum/git-sync), a handy script that backup your notes in a git repo to the remote git services. 6 | 7 | Used by OpenSource free bi-link second brain note taking & knowledge map app [TidGi-Desktop](https://github.com/tiddly-gittly/TidGi-Desktop), refactor out to be a npm package. 8 | 9 | ```shell 10 | npm i git-sync-js 11 | ``` 12 | 13 | ## Major Functions 14 | 15 | There are three major functions: `initGit, clone, commitAndSync`, but you may import other helper functions and Error types, and GitStep types: 16 | 17 | ```ts 18 | import { 19 | AssumeSyncError, 20 | CantSyncGitNotInitializedError, 21 | CantSyncInSpecialGitStateAutoFixFailed, 22 | clone, 23 | commitAndSync, 24 | getModifiedFileList, 25 | getRemoteUrl, 26 | GitPullPushError, 27 | GitStep, 28 | ILoggerContext, 29 | initGit, 30 | ModifiedFileList, 31 | SyncParameterMissingError, 32 | SyncScriptIsInDeadLoopError, 33 | } from 'git-sync-js'; 34 | ``` 35 | 36 | See [api docs](./docs/api/) for full list of them. 37 | 38 | You can see [TidGi-Desktop's usage](https://github.com/tiddly-gittly/TidGi-Desktop/blob/9e73bbf96deb5a4c085bbf9c56dc38e62efdd550/src/services/git/gitWorker.ts) for full example. 39 | 40 | ### initGit 41 | 42 | [initGit()](./docs/api/modules/initGit.md) Initialize a new `.git` on a folder. If set `syncImmediately` to `true`, it will push local git to remote immediately after init, you should provide `userInfo.accessToken` and `remoteUrl`, otherwise they are optional. 43 | 44 | ```ts 45 | try { 46 | await initGit({ 47 | dir: wikiFolderPath, 48 | remoteUrl, 49 | syncImmediately: isSyncedWiki, 50 | userInfo: { ...defaultGitInfo, ...userInfo }, 51 | logger: { 52 | log: (message: string, context: ILoggerContext): unknown => logger.info(message, { callerFunction: 'initWikiGit', ...context }), 53 | warn: (message: string, context: ILoggerContext): unknown => logger.warn(message, { callerFunction: 'initWikiGit', ...context }), 54 | info: (message: GitStep, context: ILoggerContext): void => { 55 | logger.notice(this.translateMessage(message), { 56 | handler: WikiChannel.syncProgress, 57 | callerFunction: 'initWikiGit', 58 | ...context, 59 | }); 60 | }, 61 | }, 62 | }); 63 | } catch (error) { 64 | this.translateErrorMessage(error); 65 | } 66 | ``` 67 | 68 | ### commitAndSync 69 | 70 | [commitAndSync()](./docs/api/modules/commitAndSync.md) is the Core feature of git-sync, commit all unstaged files, and try rebase on remote, and push to the remote. 71 | 72 | ```ts 73 | try { 74 | await commitAndSync({ 75 | dir: wikiFolderPath, 76 | remoteUrl, 77 | userInfo: { ...defaultGitInfo, ...userInfo }, 78 | logger: { 79 | log: (message: string, context: ILoggerContext): unknown => logger.info(message, { callerFunction: 'commitAndSync', ...context }), 80 | warn: (message: string, context: ILoggerContext): unknown => logger.warn(message, { callerFunction: 'commitAndSync', ...context }), 81 | info: (message: GitStep, context: ILoggerContext): void => { 82 | logger.notice(this.translateMessage(message), { 83 | handler: WikiChannel.syncProgress, 84 | callerFunction: 'commitAndSync', 85 | ...context, 86 | }); 87 | }, 88 | }, 89 | filesToIgnore, 90 | }); 91 | } catch (error) { 92 | this.translateErrorMessage(error); 93 | } 94 | ``` 95 | 96 | ### clone 97 | 98 | [clone()](./docs/api/modules/clone.md) will Clone a remote repo to a local location. 99 | 100 | ```ts 101 | try { 102 | await clone({ 103 | dir: repoFolderPath, 104 | remoteUrl, 105 | userInfo: { ...defaultGitInfo, ...userInfo }, 106 | logger: { 107 | log: (message: string, context: ILoggerContext): unknown => logger.info(message, { callerFunction: 'clone', ...context }), 108 | warn: (message: string, context: ILoggerContext): unknown => logger.warn(message, { callerFunction: 'clone', ...context }), 109 | info: (message: GitStep, context: ILoggerContext): void => { 110 | logger.notice(this.translateMessage(message), { 111 | handler: WikiChannel.syncProgress, 112 | callerFunction: 'clone', 113 | ...context, 114 | }); 115 | }, 116 | }, 117 | }); 118 | } catch (error) { 119 | this.translateErrorMessage(error); 120 | } 121 | ``` 122 | 123 | ## Inspect helpers 124 | 125 | ### getModifiedFileList 126 | 127 | Get modified files and modify type in a folder 128 | 129 | ```ts 130 | await getModifiedFileList(wikiFolderPath); 131 | ``` 132 | 133 | ### getDefaultBranchName 134 | 135 | ### getSyncState 136 | 137 | ### assumeSync 138 | 139 | ### getGitRepositoryState 140 | 141 | ### getGitDirectory 142 | 143 | ### hasGit 144 | 145 | Check if dir has `.git`. 146 | 147 | ## Sync helpers 148 | 149 | ### commitFiles 150 | 151 | ### continueRebase 152 | 153 | ## Steps 154 | 155 | These is a git sync steps enum [GitStep](./docs/api/enums/interface.GitStep.md), that will log to logger when steps happened. You can write switch case on them in your custom logger, and translate them into user readable info. 156 | 157 | ```shell 158 | StartGitInitialization 159 | PrepareCloneOnlineWiki 160 | GitRepositoryConfigurationFinished 161 | StartConfiguringGithubRemoteRepository 162 | StartBackupToGitRemote 163 | PrepareSync 164 | HaveThingsToCommit 165 | AddingFiles 166 | AddComplete 167 | CommitComplete 168 | PreparingUserInfo 169 | FetchingData 170 | NoNeedToSync 171 | LocalAheadStartUpload 172 | CheckingLocalSyncState 173 | CheckingLocalGitRepoSanity 174 | LocalStateBehindSync 175 | LocalStateDivergeRebase 176 | RebaseResultChecking 177 | RebaseConflictNeedsResolve 178 | RebaseSucceed 179 | GitPushFailed 180 | GitMergeFailed 181 | SyncFailedAlgorithmWrong 182 | PerformLastCheckBeforeSynchronizationFinish 183 | SynchronizationFinish 184 | StartFetchingFromGithubRemote 185 | CantSyncInSpecialGitStateAutoFixSucceed 186 | ``` 187 | 188 | ## Errors 189 | 190 | These are the errors like [AssumeSyncError](./docs/api/classes/errors.AssumeSyncError.md) that will throw on git sync gets into fatal situations. You can try catch on major functions to get these errors, and `instanceof` these error to translate their message for user to read and report. 191 | 192 | ```shell 193 | AssumeSyncError 194 | SyncParameterMissingError 195 | GitPullPushError 196 | CantSyncGitNotInitializedError 197 | SyncScriptIsInDeadLoopError 198 | CantSyncInSpecialGitStateAutoFixFailed 199 | ``` 200 | -------------------------------------------------------------------------------- /test/credential.test.ts: -------------------------------------------------------------------------------- 1 | import { credentialOff, credentialOn, getGitHubUrlWithCredential, getRemoteUrl } from '../src'; 2 | import { defaultGitInfo } from '../src/defaultGitInfo'; 3 | import { getGitUrlWithGitSuffix, getGitUrlWithOutGitSuffix } from '../src/utils'; 4 | import { creatorGitInfo, dir, exampleRemoteUrl, exampleToken } from './constants'; 5 | import { addHTTPRemote } from './utils'; 6 | 7 | describe('credential', () => { 8 | beforeEach(async () => { 9 | await addHTTPRemote(); 10 | }); 11 | 12 | test('it has remote with token after calling credentialOn', async () => { 13 | await credentialOn(dir, exampleRemoteUrl, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 14 | const remoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 15 | // make sure we are working on a https remote, our method only handle this case 16 | expect(remoteUrl).toStartWith('https://'); 17 | expect(remoteUrl.length).toBeGreaterThan(0); 18 | expect(remoteUrl).toBe(getGitHubUrlWithCredential(exampleRemoteUrl, defaultGitInfo.gitUserName, exampleToken)); 19 | // github use https://${username}:${accessToken}@github.com/ format 20 | expect(remoteUrl.includes('@')).toBe(true); 21 | expect(remoteUrl.includes(exampleToken)).toBe(true); 22 | // we want user add .git himself before credentialOn 23 | expect(remoteUrl.endsWith('.git')).toBe(false); 24 | }); 25 | 26 | test('it has a credential-free remote with .git suffix after calling credentialOff', async () => { 27 | await credentialOn(dir, exampleRemoteUrl, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 28 | await credentialOff(dir, defaultGitInfo.remote); 29 | const remoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 30 | expect(remoteUrl.length).toBeGreaterThan(0); 31 | expect(remoteUrl).toBe(exampleRemoteUrl); 32 | expect(remoteUrl.includes('@')).toBe(false); 33 | expect(remoteUrl.includes(exampleToken)).toBe(false); 34 | expect(remoteUrl.endsWith('.git')).toBe(false); 35 | }); 36 | 37 | test('it keeps .git suffix, letting user add and remove it', async () => { 38 | const exampleRemoteUrlWithSuffix = getGitUrlWithGitSuffix(exampleRemoteUrl); 39 | expect(exampleRemoteUrlWithSuffix.endsWith('.git')).toBe(true); 40 | await credentialOn(dir, exampleRemoteUrlWithSuffix, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 41 | const newRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 42 | expect(newRemoteUrl.endsWith('.git')).toBe(true); 43 | await credentialOff(dir, defaultGitInfo.remote); 44 | const restoredRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 45 | expect(restoredRemoteUrl.endsWith('.git')).toBe(true); 46 | const remoteUrlWithoutSuffix = getGitUrlWithOutGitSuffix(restoredRemoteUrl); 47 | expect(remoteUrlWithoutSuffix.endsWith('.git')).toBe(false); 48 | }); 49 | 50 | test('it remove token after off', async () => { 51 | const originalRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 52 | await credentialOn(dir, originalRemoteUrl, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 53 | const newRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 54 | expect(newRemoteUrl.includes(exampleToken)).toBe(true); 55 | await credentialOff(dir, defaultGitInfo.remote); 56 | const restoredRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 57 | expect(restoredRemoteUrl).toBe(originalRemoteUrl); 58 | expect(restoredRemoteUrl.includes(exampleToken)).toBe(false); 59 | }); 60 | 61 | test('it remove Github token after off (specific case of creator)', async () => { 62 | const creatorRepoUrl = `https://github.com/${creatorGitInfo.gitUserName}/wiki`; 63 | const creatorRepoUrlWithToken = `https://${creatorGitInfo.gitUserName}:${creatorGitInfo.accessToken}@github.com/${creatorGitInfo.gitUserName}/wiki`; 64 | await credentialOn(dir, creatorRepoUrl, creatorGitInfo.gitUserName, creatorGitInfo.accessToken, defaultGitInfo.remote); 65 | const newRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 66 | expect(newRemoteUrl.includes(creatorGitInfo.accessToken)).toBe(true); 67 | expect(newRemoteUrl).toBe(creatorRepoUrlWithToken); 68 | await credentialOff(dir, defaultGitInfo.remote); 69 | const restoredRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 70 | expect(restoredRemoteUrl).toBe(creatorRepoUrl); 71 | expect(restoredRemoteUrl.includes(exampleToken)).toBe(false); 72 | }); 73 | 74 | test('it remove Github token from url with token (specific case of creator)', async () => { 75 | const creatorRepoUrl = `https://github.com/${creatorGitInfo.gitUserName}/wiki`; 76 | const creatorRepoUrlWithToken = `https://${creatorGitInfo.gitUserName}:${creatorGitInfo.accessToken}@github.com/${creatorGitInfo.gitUserName}/wiki`; 77 | // sometimes, original url has token (forget to remove) due to bugs in previous versions. 78 | await credentialOn(dir, creatorRepoUrlWithToken, creatorGitInfo.gitUserName, creatorGitInfo.accessToken, defaultGitInfo.remote); 79 | const newRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 80 | expect(newRemoteUrl.includes(creatorGitInfo.accessToken)).toBe(true); 81 | expect(newRemoteUrl).toBe(creatorRepoUrlWithToken); 82 | await credentialOff(dir, defaultGitInfo.remote); 83 | const restoredRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 84 | expect(restoredRemoteUrl).toBe(creatorRepoUrl); 85 | expect(restoredRemoteUrl.includes(exampleToken)).toBe(false); 86 | }); 87 | 88 | test('methods are idempotent', async () => { 89 | const originalRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 90 | await credentialOn(dir, originalRemoteUrl, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 91 | const newRemoteUrl1 = await getRemoteUrl(dir, defaultGitInfo.remote); 92 | expect(newRemoteUrl1.includes(exampleToken)).toBe(true); 93 | await credentialOn(dir, newRemoteUrl1, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 94 | await credentialOn(dir, newRemoteUrl1, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 95 | await credentialOn(dir, newRemoteUrl1, defaultGitInfo.gitUserName, exampleToken, defaultGitInfo.remote); 96 | const newRemoteUrl2 = await getRemoteUrl(dir, defaultGitInfo.remote); 97 | expect(newRemoteUrl2.includes(exampleToken)).toBe(true); 98 | expect(newRemoteUrl2).toBe(newRemoteUrl1); 99 | await credentialOff(dir, defaultGitInfo.remote); 100 | await credentialOff(dir, defaultGitInfo.remote); 101 | await credentialOff(dir, defaultGitInfo.remote); 102 | const restoredRemoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 103 | expect(restoredRemoteUrl).toBe(originalRemoteUrl); 104 | expect(restoredRemoteUrl.includes(exampleToken)).toBe(false); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/commitAndSync.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import { credentialOff, credentialOn } from './credential'; 3 | import { defaultGitInfo as defaultDefaultGitInfo } from './defaultGitInfo'; 4 | import { CantSyncGitNotInitializedError, GitPullPushError, SyncParameterMissingError } from './errors'; 5 | import { assumeSync, getDefaultBranchName, getGitRepositoryState, getRemoteName, getSyncState, haveLocalChanges, removeGitLockFiles } from './inspect'; 6 | import { GitStep, IGitUserInfos, ILogger } from './interface'; 7 | import { commitFiles, continueRebase, fetchRemote, mergeUpstream, pushUpstream } from './sync'; 8 | import { toGitStringResult } from './utils'; 9 | 10 | export interface ICommitAndSyncOptions { 11 | /** the commit message */ 12 | commitMessage?: string; 13 | commitOnly?: boolean; 14 | /** Optional fallback of userInfo. If some info is missing in userInfo, will use defaultGitInfo instead. */ 15 | defaultGitInfo?: typeof defaultDefaultGitInfo; 16 | /** wiki folder path, can be relative */ 17 | dir: string; 18 | /** if you want to use a dynamic .gitignore, you can passing an array contains filepaths that want to ignore */ 19 | filesToIgnore?: string[]; 20 | logger?: ILogger; 21 | /** the storage service url we are sync to, for example your github repo url 22 | * When empty, and commitOnly===true, it means we just want commit, without sync 23 | */ 24 | remoteUrl?: string; 25 | /** user info used in the commit message 26 | * When empty, and commitOnly===true, it means we just want commit, without sync 27 | */ 28 | userInfo?: IGitUserInfos; 29 | } 30 | /** 31 | * `git add .` + `git commit` + `git rebase` or something that can sync bi-directional 32 | */ 33 | export async function commitAndSync(options: ICommitAndSyncOptions): Promise { 34 | const { 35 | dir, 36 | remoteUrl, 37 | commitMessage = 'Updated with Git-Sync', 38 | userInfo, 39 | logger, 40 | defaultGitInfo = defaultDefaultGitInfo, 41 | filesToIgnore, 42 | commitOnly, 43 | } = options; 44 | const { gitUserName, email, branch } = userInfo ?? defaultGitInfo; 45 | const { accessToken } = userInfo ?? {}; 46 | 47 | const defaultBranchName = (await getDefaultBranchName(dir)) ?? branch; 48 | const remoteName = await getRemoteName(dir, defaultBranchName); 49 | 50 | const logProgress = (step: GitStep): unknown => 51 | logger?.info?.(step, { 52 | functionName: 'commitAndSync', 53 | step, 54 | dir, 55 | remoteUrl, 56 | branch: defaultBranchName, 57 | }); 58 | const logDebug = (message: string, step: GitStep): unknown => 59 | logger?.debug?.(message, { 60 | functionName: 'commitAndSync', 61 | step, 62 | dir, 63 | remoteUrl, 64 | branch: defaultBranchName, 65 | }); 66 | const logWarn = (message: string, step: GitStep): unknown => 67 | logger?.warn?.(message, { 68 | functionName: 'commitAndSync', 69 | step, 70 | dir, 71 | remoteUrl, 72 | branch: defaultBranchName, 73 | }); 74 | 75 | // preflight check 76 | await syncPreflightCheck({ 77 | dir, 78 | logger, 79 | logProgress, 80 | logDebug, 81 | defaultGitInfo, 82 | userInfo, 83 | }); 84 | 85 | if (await haveLocalChanges(dir)) { 86 | logProgress(GitStep.HaveThingsToCommit); 87 | logDebug(commitMessage, GitStep.HaveThingsToCommit); 88 | const { exitCode: commitExitCode, stderr: commitStdError } = await commitFiles( 89 | dir, 90 | gitUserName, 91 | email ?? defaultGitInfo.email, 92 | commitMessage, 93 | filesToIgnore, 94 | logger, 95 | ); 96 | if (commitExitCode !== 0) { 97 | logWarn(`commit failed ${commitStdError}`, GitStep.CommitComplete); 98 | } 99 | logProgress(GitStep.CommitComplete); 100 | } 101 | if (commitOnly === true) { 102 | return; 103 | } 104 | logProgress(GitStep.PreparingUserInfo); 105 | if (accessToken === '' || accessToken === undefined) { 106 | throw new SyncParameterMissingError('accessToken'); 107 | } 108 | if (remoteUrl === '' || remoteUrl === undefined) { 109 | throw new SyncParameterMissingError('remoteUrl'); 110 | } 111 | await credentialOn(dir, remoteUrl, gitUserName, accessToken, remoteName); 112 | logProgress(GitStep.FetchingData); 113 | try { 114 | await fetchRemote(dir, remoteName, defaultBranchName, logger); 115 | let exitCode = 0; 116 | let stderr: string | undefined; 117 | const syncStateAfterCommit = await getSyncState(dir, defaultBranchName, remoteName, logger); 118 | switch (syncStateAfterCommit) { 119 | case 'equal': { 120 | logProgress(GitStep.NoNeedToSync); 121 | return; 122 | } 123 | case 'noUpstreamOrBareUpstream': { 124 | logProgress(GitStep.NoUpstreamCantPush); 125 | // try push, if success, means it is bare, otherwise, it is no upstream 126 | try { 127 | await pushUpstream(dir, defaultBranchName, remoteName, userInfo, logger); 128 | break; 129 | } catch (error) { 130 | logWarn( 131 | `${JSON.stringify({ dir, remoteUrl, userInfo })}, remoteUrl may be not valid, noUpstreamOrBareUpstream after credentialOn`, 132 | GitStep.NoUpstreamCantPush, 133 | ); 134 | throw error; 135 | } 136 | } 137 | case 'ahead': { 138 | logProgress(GitStep.LocalAheadStartUpload); 139 | await pushUpstream(dir, defaultBranchName, remoteName, userInfo, logger); 140 | break; 141 | } 142 | case 'behind': { 143 | logProgress(GitStep.LocalStateBehindSync); 144 | await mergeUpstream(dir, defaultBranchName, remoteName, userInfo, logger); 145 | break; 146 | } 147 | case 'diverged': { 148 | logProgress(GitStep.LocalStateDivergeRebase); 149 | const rebaseResult = toGitStringResult(await exec(['rebase', `${remoteName}/${defaultBranchName}`], dir)); 150 | exitCode = rebaseResult.exitCode; 151 | stderr = rebaseResult.stderr; 152 | logProgress(GitStep.RebaseResultChecking); 153 | if (exitCode !== 0) { 154 | logWarn(`exitCode: ${exitCode}, stderr of git rebase: ${stderr}`, GitStep.RebaseResultChecking); 155 | } 156 | if ( 157 | exitCode === 0 && 158 | (await getGitRepositoryState(dir, logger)).length === 0 && 159 | (await getSyncState(dir, defaultBranchName, remoteName, logger)) === 'ahead' 160 | ) { 161 | logProgress(GitStep.RebaseSucceed); 162 | } else { 163 | await continueRebase(dir, gitUserName, email ?? defaultGitInfo.email, logger); 164 | logProgress(GitStep.RebaseConflictNeedsResolve); 165 | } 166 | await pushUpstream(dir, defaultBranchName, remoteName, userInfo, logger); 167 | break; 168 | } 169 | default: { 170 | logProgress(GitStep.SyncFailedAlgorithmWrong); 171 | } 172 | } 173 | 174 | if (exitCode === 0) { 175 | logProgress(GitStep.PerformLastCheckBeforeSynchronizationFinish); 176 | await assumeSync(dir, defaultBranchName, remoteName, logger); 177 | logProgress(GitStep.SynchronizationFinish); 178 | } else { 179 | switch (exitCode) { 180 | // "message":"exitCode: 128, stderr of git push: fatal: unable to access 'https://github.com/tiddly-gittly/TiddlyWiki-Chinese-Tutorial.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443 \n" 181 | case 128: { 182 | throw new GitPullPushError(options, stderr ?? ''); 183 | } 184 | // TODO: handle auth expire and throw here 185 | default: { 186 | throw new GitPullPushError(options, stderr ?? ''); 187 | } 188 | } 189 | } 190 | } finally { 191 | // always restore original remoteUrl without token 192 | await credentialOff(dir, remoteName, remoteUrl); 193 | } 194 | } 195 | 196 | /** 197 | * Check for git repo state, if it is not clean, try fix it. If not init will throw error. 198 | * This method is used by commitAndSync and forcePull before they doing anything. 199 | */ 200 | export async function syncPreflightCheck(configs: { 201 | /** defaultGitInfo from ICommitAndSyncOptions */ 202 | defaultGitInfo?: typeof defaultDefaultGitInfo; 203 | dir: string; 204 | logDebug?: (message: string, step: GitStep) => unknown; 205 | logProgress?: (step: GitStep) => unknown; 206 | logger?: ILogger; 207 | /** userInfo from ICommitAndSyncOptions */ 208 | userInfo?: IGitUserInfos; 209 | }) { 210 | const { dir, logger, logProgress, logDebug, defaultGitInfo = defaultDefaultGitInfo, userInfo } = configs; 211 | const { gitUserName, email } = userInfo ?? defaultGitInfo; 212 | 213 | // Check and remove stale lock files before any git operations 214 | const removedLockFiles = await removeGitLockFiles(dir, logger); 215 | if (removedLockFiles > 0) { 216 | logDebug?.(`Removed ${removedLockFiles} stale git lock file(s) before sync`, GitStep.PrepareSync); 217 | } 218 | 219 | const repoStartingState = await getGitRepositoryState(dir, logger); 220 | if (repoStartingState.length === 0 || repoStartingState === '|DIRTY') { 221 | logProgress?.(GitStep.PrepareSync); 222 | logDebug?.(`${dir} repoStartingState: ${repoStartingState}, ${gitUserName} <${email ?? defaultGitInfo.email}>`, GitStep.PrepareSync); 223 | } else if (repoStartingState === 'NOGIT') { 224 | throw new CantSyncGitNotInitializedError(dir); 225 | } else { 226 | // we may be in middle of a rebase, try fix that 227 | await continueRebase(dir, gitUserName, email ?? defaultGitInfo.email, logger, repoStartingState); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/sync.ts: -------------------------------------------------------------------------------- 1 | import { exec, type IGitStringResult } from 'dugite'; 2 | 3 | import { CantSyncInSpecialGitStateAutoFixFailed, GitPullPushError, SyncScriptIsInDeadLoopError } from './errors'; 4 | import { getGitRepositoryState } from './inspect'; 5 | import { GitStep, IGitUserInfos, IGitUserInfosWithoutToken, ILogger } from './interface'; 6 | import { toGitStringResult } from './utils'; 7 | 8 | /** 9 | * Git add and commit all file 10 | * @param dir 11 | * @param username 12 | * @param email 13 | * @param message 14 | */ 15 | export async function commitFiles( 16 | dir: string, 17 | username: string, 18 | email: string, 19 | message = 'Commit with Git-Sync-JS', 20 | filesToIgnore: string[] = [], 21 | logger?: ILogger, 22 | ): Promise { 23 | const logProgress = (step: GitStep): unknown => 24 | logger?.info(step, { 25 | functionName: 'commitFiles', 26 | step, 27 | dir, 28 | }); 29 | const logDebug = (message: string, step: GitStep): unknown => 30 | logger?.debug(message, { 31 | functionName: 'commitFiles', 32 | step, 33 | dir, 34 | }); 35 | 36 | logProgress(GitStep.AddingFiles); 37 | logDebug(`Executing: git add . in ${dir}`, GitStep.AddingFiles); 38 | const addResult = toGitStringResult(await exec(['add', '.'], dir)); 39 | logDebug(`git add exitCode: ${addResult.exitCode}, stdout: ${addResult.stdout || '(empty)'}, stderr: ${addResult.stderr || '(empty)'}`, GitStep.AddingFiles); 40 | 41 | // Check what's actually in the staging area 42 | const statusResult = toGitStringResult(await exec(['status', '--porcelain'], dir)); 43 | logDebug(`git status --porcelain: ${statusResult.stdout || '(empty)'}`, GitStep.AddingFiles); 44 | 45 | // Check staged files using git diff --cached 46 | const diffCachedResult = toGitStringResult(await exec(['diff', '--cached', '--name-only'], dir)); 47 | const actualStagedFiles = diffCachedResult.stdout.trim().split('\n').filter(f => f.length > 0); 48 | logDebug(`Actual staged files count (from git diff --cached): ${actualStagedFiles.length}`, GitStep.AddingFiles); 49 | if (actualStagedFiles.length > 0) { 50 | logDebug( 51 | `Actual staged files: ${actualStagedFiles.slice(0, 10).join(', ')}${actualStagedFiles.length > 10 ? ` ... (${actualStagedFiles.length - 10} more)` : ''}`, 52 | GitStep.AddingFiles, 53 | ); 54 | } else { 55 | logDebug('No files in staging area after git add!', GitStep.AddingFiles); 56 | } 57 | 58 | // find and unStage files that are in the ignore list 59 | if (filesToIgnore.length > 0) { 60 | // Get all tracked files using git ls-files 61 | const lsFilesResult = toGitStringResult(await exec(['ls-files'], dir)); 62 | const trackedFiles = lsFilesResult.stdout.trim().split('\n').filter(f => f.length > 0); 63 | logDebug(`Total tracked files count (from git ls-files): ${trackedFiles.length}`, GitStep.AddingFiles); 64 | 65 | const stagedFilesToIgnore = filesToIgnore.filter((file) => trackedFiles.includes(file)); 66 | logDebug(`Files to ignore count: ${filesToIgnore.length}, staged files to ignore count: ${stagedFilesToIgnore.length}`, GitStep.AddingFiles); 67 | if (stagedFilesToIgnore.length > 0) { 68 | logDebug(`Unstaging files: ${stagedFilesToIgnore.join(', ')}`, GitStep.AddingFiles); 69 | // Use git reset to unstage files 70 | await Promise.all(stagedFilesToIgnore.map(async (file) => { 71 | await exec(['reset', 'HEAD', file], dir); 72 | })); 73 | // Re-check staging area after removal 74 | const diffCachedAfterRemove = toGitStringResult(await exec(['diff', '--cached', '--name-only'], dir)); 75 | const remainingStagedFiles = diffCachedAfterRemove.stdout.trim().split('\n').filter(f => f.length > 0); 76 | logDebug(`Remaining staged files count after unstaging: ${remainingStagedFiles.length}`, GitStep.AddingFiles); 77 | } 78 | } 79 | 80 | logProgress(GitStep.AddComplete); 81 | logDebug(`Executing: git commit -m "${message}" --author="${username} <${email}>" with committer env vars`, GitStep.CommitComplete); 82 | const commitResult = toGitStringResult( 83 | await exec( 84 | ['commit', '-m', message, `--author="${username} <${email}>"`], 85 | dir, 86 | { 87 | env: { 88 | ...process.env, 89 | GIT_COMMITTER_NAME: username, 90 | GIT_COMMITTER_EMAIL: email, 91 | }, 92 | }, 93 | ), 94 | ); 95 | logDebug(`git commit exitCode: ${commitResult.exitCode}, stdout: ${commitResult.stdout || '(empty)'}, stderr: ${commitResult.stderr || '(empty)'}`, GitStep.CommitComplete); 96 | 97 | if (commitResult.exitCode === 1 && commitResult.stdout.includes('nothing to commit')) { 98 | logDebug('Git commit reports "nothing to commit" - this is expected if staging area is empty', GitStep.CommitComplete); 99 | } else if (commitResult.exitCode === 128 && commitResult.stderr.includes('Committer identity unknown')) { 100 | logDebug('Git commit failed due to committer identity - this should not happen with env vars set', GitStep.CommitComplete); 101 | } 102 | 103 | return commitResult; 104 | } 105 | 106 | /** 107 | * Git push -f origin master 108 | * This does force push, to deal with `--allow-unrelated-histories` case 109 | * @param dir 110 | * @param username 111 | * @param email 112 | * @param message 113 | */ 114 | export async function pushUpstream( 115 | dir: string, 116 | branch: string, 117 | remoteName: string, 118 | userInfo?: IGitUserInfos | IGitUserInfosWithoutToken, 119 | logger?: ILogger, 120 | ): Promise { 121 | const logProgress = (step: GitStep): unknown => 122 | logger?.info(step, { 123 | functionName: 'pushUpstream', 124 | step, 125 | dir, 126 | }); 127 | /** when push to remote, we need to specify the local branch name and remote branch name */ 128 | const branchMapping = `${branch}:${branch}`; 129 | logProgress(GitStep.GitPush); 130 | const pushResult = toGitStringResult(await exec(['push', remoteName, branchMapping], dir)); 131 | logProgress(GitStep.GitPushComplete); 132 | if (pushResult.exitCode !== 0) { 133 | throw new GitPullPushError({ dir, branch, remote: remoteName, userInfo }, pushResult.stdout + pushResult.stderr); 134 | } 135 | return pushResult; 136 | } 137 | 138 | /** 139 | * Git merge origin master 140 | * @param dir 141 | * @param username 142 | * @param email 143 | * @param message 144 | */ 145 | export async function mergeUpstream( 146 | dir: string, 147 | branch: string, 148 | remoteName: string, 149 | userInfo?: IGitUserInfos | IGitUserInfosWithoutToken, 150 | logger?: ILogger, 151 | ): Promise { 152 | const logProgress = (step: GitStep): unknown => 153 | logger?.info(step, { 154 | functionName: 'mergeUpstream', 155 | step, 156 | dir, 157 | }); 158 | logProgress(GitStep.GitMerge); 159 | const mergeResult = toGitStringResult(await exec(['merge', '--ff', '--ff-only', `${remoteName}/${branch}`], dir)); 160 | logProgress(GitStep.GitMergeComplete); 161 | if (mergeResult.exitCode !== 0) { 162 | throw new GitPullPushError({ dir, branch, remote: remoteName, userInfo }, mergeResult.stdout + mergeResult.stderr); 163 | } 164 | 165 | return mergeResult; 166 | } 167 | 168 | /** 169 | * try to continue rebase, simply adding and committing all things, leave them to user to resolve in the TiddlyWiki later. 170 | * @param dir 171 | * @param username 172 | * @param email 173 | * @param providedRepositoryState result of `await getGitRepositoryState(dir, logger)`, optional, if not provided, we will run `await getGitRepositoryState(dir, logger)` by ourself. 174 | */ 175 | export async function continueRebase(dir: string, username: string, email: string, logger?: ILogger, providedRepositoryState?: string): Promise { 176 | const logProgress = (step: GitStep): unknown => 177 | logger?.info(step, { 178 | functionName: 'continueRebase', 179 | step, 180 | dir, 181 | }); 182 | 183 | let hasNotCommittedConflict = true; 184 | let rebaseContinueExitCode = 0; 185 | let rebaseContinueStdError = ''; 186 | let repositoryState: string = providedRepositoryState ?? (await getGitRepositoryState(dir, logger)); 187 | // prevent infin loop, if there is some bug that I miss 188 | let loopCount = 0; 189 | while (hasNotCommittedConflict) { 190 | loopCount += 1; 191 | if (loopCount > 1000) { 192 | throw new SyncScriptIsInDeadLoopError(); 193 | } 194 | const { exitCode: commitExitCode, stderr: commitStdError } = await commitFiles(dir, username, email, 'Conflict files committed with Git-Sync-JS', [], logger); 195 | const rebaseContinueResult = toGitStringResult(await exec(['rebase', '--continue'], dir)); 196 | // get info for logging 197 | rebaseContinueExitCode = rebaseContinueResult.exitCode; 198 | rebaseContinueStdError = rebaseContinueResult.stderr; 199 | const rebaseContinueStdOut = rebaseContinueResult.stdout; 200 | repositoryState = await getGitRepositoryState(dir, logger); 201 | // if git add . + git commit failed or git rebase --continue failed 202 | if (commitExitCode !== 0 || rebaseContinueExitCode !== 0) { 203 | throw new CantSyncInSpecialGitStateAutoFixFailed( 204 | `rebaseContinueStdError when ${repositoryState}: ${rebaseContinueStdError}\ncommitStdError when ${repositoryState}: ${commitStdError}\n${rebaseContinueStdError}`, 205 | ); 206 | } 207 | hasNotCommittedConflict = rebaseContinueStdError.startsWith('CONFLICT') || rebaseContinueStdOut.startsWith('CONFLICT'); 208 | } 209 | logProgress(GitStep.CantSyncInSpecialGitStateAutoFixSucceed); 210 | } 211 | 212 | /** 213 | * Simply calling git fetch. 214 | * @param branch if not provided, will fetch all branches 215 | */ 216 | export async function fetchRemote(dir: string, remoteName: string, branch?: string, logger?: ILogger) { 217 | const logDebug = (message: string): unknown => 218 | logger?.debug(message, { 219 | functionName: 'fetchRemote', 220 | step: GitStep.FetchingData, 221 | dir, 222 | }); 223 | 224 | logDebug(`Fetching from ${remoteName}${branch ? ` branch ${branch}` : ' all branches'}`); 225 | 226 | if (branch === undefined) { 227 | await exec(['fetch', remoteName], dir); 228 | } else { 229 | await exec(['fetch', remoteName, branch], dir); 230 | } 231 | 232 | logDebug(`Fetch completed from ${remoteName}`); 233 | } 234 | -------------------------------------------------------------------------------- /test/inspect.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import fs from 'fs-extra'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { defaultGitInfo } from '../src/defaultGitInfo'; 6 | import { AssumeSyncError } from '../src/errors'; 7 | import { 8 | assumeSync, 9 | getDefaultBranchName, 10 | getGitDirectory, 11 | getGitRepositoryState, 12 | getModifiedFileList, 13 | getRemoteName, 14 | getRemoteRepoName, 15 | getRemoteUrl, 16 | getSyncState, 17 | hasGit, 18 | haveLocalChanges, 19 | SyncState, 20 | } from '../src/inspect'; 21 | import { fetchRemote, pushUpstream } from '../src/sync'; 22 | import { dir, exampleImageBuffer, exampleRemoteUrl, exampleRepoName, exampleToken, gitDirectory, gitSyncRepoDirectoryGitDirectory, upstreamDir } from './constants'; 23 | import { addAndCommitUsingDugite, addAnUpstream, addSomeFiles, anotherRepo2PushSomeFiles, createAndSyncRepo2ToRemote } from './utils'; 24 | 25 | describe('getGitDirectory', () => { 26 | test('echo the git dir, hasGit is true', async () => { 27 | expect(await getGitDirectory(dir)).toBe(gitDirectory); 28 | expect(await hasGit(dir)).toBe(true); 29 | }); 30 | 31 | describe('when no git', () => { 32 | beforeEach(async () => { 33 | await fs.remove(gitDirectory); 34 | }); 35 | test("the git dir will be git-sync's dir", async () => { 36 | expect(await getGitDirectory(dir)).toBe(gitSyncRepoDirectoryGitDirectory); 37 | }); 38 | 39 | test('hasGit is false when strictly no git', async () => { 40 | // will detect git-sync's repo git 41 | expect(await hasGit(dir, false)).toBe(true); 42 | // default is strictly check 43 | expect(await hasGit(dir)).toBe(false); 44 | }); 45 | }); 46 | }); 47 | 48 | describe('getDefaultBranchName', () => { 49 | test('return undefined on a non git folder', async () => { 50 | const branch = await getDefaultBranchName(os.tmpdir()); 51 | expect(branch).toBe(undefined); 52 | }); 53 | 54 | test('return undefined on a not existed folder', async () => { 55 | const branch = await getDefaultBranchName(os.tmpdir() + '/not-existed'); 56 | expect(branch).toBe(undefined); 57 | }); 58 | 59 | test('it is main now due to BLM activities', async () => { 60 | const branch = await getDefaultBranchName(dir); 61 | expect(branch).toBe('main'); 62 | }); 63 | 64 | test("But if we are still using master because there wasn't a black man slavery history in Chinese", async () => { 65 | await exec(['branch', '-m', 'main', 'master'], dir); 66 | const branch = await getDefaultBranchName(dir); 67 | expect(branch).toBe('master'); 68 | }); 69 | }); 70 | 71 | describe('getModifiedFileList', () => { 72 | test('list multiple English file names in different ext name', async () => { 73 | const paths = await addSomeFiles(); 74 | const fileList = await getModifiedFileList(dir); 75 | expect(fileList).toStrictEqual([ 76 | { filePath: path.normalize(paths[0]), fileRelativePath: paths[0].replace(`${dir}/`, ''), type: '??' }, 77 | { filePath: path.normalize(paths[1]), fileRelativePath: paths[1].replace(`${dir}/`, ''), type: '??' }, 78 | ]); 79 | }); 80 | 81 | test('list multiple CJK file names', async () => { 82 | const paths: [string, string] = [path.join(dir, '试试啊.json'), path.join(dir, '一个破图片.png')]; 83 | await fs.writeJSON(paths[0], { test: 'test' }); 84 | await fs.writeFile(paths[1], exampleImageBuffer); 85 | const fileList = await getModifiedFileList(dir); 86 | expect(fileList).toStrictEqual([ 87 | { filePath: paths[0], fileRelativePath: '试试啊.json', type: '??' }, 88 | { filePath: paths[1], fileRelativePath: '一个破图片.png', type: '??' }, 89 | ]); 90 | }); 91 | }); 92 | 93 | describe('getRemoteUrl', () => { 94 | test("New repo don't have remote", async () => { 95 | const remoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 96 | expect(remoteUrl).toBe(''); 97 | }); 98 | 99 | test('have remote after add upstream', async () => { 100 | await addAnUpstream(); 101 | const remoteUrl = await getRemoteUrl(dir, defaultGitInfo.remote); 102 | expect(path.normalize(remoteUrl)).toBe(upstreamDir); 103 | }); 104 | }); 105 | 106 | describe('getRemoteRepoName', () => { 107 | test('Get github repo name', () => { 108 | const repoName = getRemoteRepoName(exampleRemoteUrl); 109 | expect(repoName).toBe(exampleRepoName); 110 | }); 111 | test('Get gitlab repo name', () => { 112 | const repoName = getRemoteRepoName('https://code.byted.org/ad/bytedance-secret-notes'); 113 | expect(repoName).toBe('ad/bytedance-secret-notes'); 114 | }); 115 | 116 | test('Return undefined from malformed url', () => { 117 | const repoName = getRemoteRepoName('https://asdfasdf-asdfadsf'); 118 | expect(repoName).toBe(undefined); 119 | }); 120 | 121 | test('Return last slash when unknown', () => { 122 | const repoName = getRemoteRepoName('https://asdfasdf/asdfadsf'); 123 | expect(repoName).toBe('asdfadsf'); 124 | }); 125 | 126 | test('Throw when not a url', () => { 127 | expect(() => getRemoteRepoName('sdfasdf/asdfadsf')).toThrow(new TypeError('Invalid URL')); 128 | }); 129 | }); 130 | 131 | describe('getRemoteName', () => { 132 | test('Get default origin when no config found', async () => { 133 | const remoteName = await getRemoteName(dir, defaultGitInfo.branch); 134 | expect(remoteName).toBe(defaultGitInfo.remote); 135 | }); 136 | }); 137 | 138 | describe('haveLocalChanges', () => { 139 | test('When there are newly added files', async () => { 140 | expect(await haveLocalChanges(dir)).toBe(false); 141 | }); 142 | 143 | describe('we touch some files', () => { 144 | beforeEach(async () => { 145 | await addSomeFiles(); 146 | }); 147 | test('When there are newly added files', async () => { 148 | expect(await haveLocalChanges(dir)).toBe(true); 149 | }); 150 | 151 | test('No change after commit', async () => { 152 | await addAndCommitUsingDugite(dir, async () => { 153 | expect(await haveLocalChanges(dir)).toBe(true); 154 | }); 155 | expect(await haveLocalChanges(dir)).toBe(false); 156 | }); 157 | }); 158 | }); 159 | 160 | describe('getSyncState and getGitRepositoryState', () => { 161 | test('It should have no upstream by default', async () => { 162 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 163 | }); 164 | 165 | describe('Add a repo as the upstream', () => { 166 | beforeEach(async () => { 167 | await addAnUpstream(); 168 | }); 169 | 170 | test('have a mock upstream', async () => { 171 | expect(path.normalize(await getRemoteUrl(dir, defaultGitInfo.remote))).toBe(upstreamDir); 172 | }); 173 | test('upstream is bare', async () => { 174 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 175 | await expect(async () => { 176 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 177 | }).rejects.toThrow(new AssumeSyncError('noUpstreamOrBareUpstream')); 178 | }); 179 | test('still bare there are newly added files', async () => { 180 | await addSomeFiles(); 181 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('noUpstreamOrBareUpstream'); 182 | }); 183 | 184 | describe('push to make it equal', () => { 185 | beforeEach(async () => { 186 | // make it equal 187 | await pushUpstream(dir, defaultGitInfo.branch, defaultGitInfo.remote, { ...defaultGitInfo, accessToken: exampleToken }); 188 | }); 189 | test('ahead after commit', async () => { 190 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 191 | 192 | await addSomeFiles(); 193 | await addAndCommitUsingDugite(); 194 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('ahead'); 195 | await expect(async () => { 196 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 197 | }).rejects.toThrow(new AssumeSyncError('ahead')); 198 | }); 199 | 200 | test('behind after repo2 modify the remote', async () => { 201 | // repo2 modify the remote, make us behind 202 | await createAndSyncRepo2ToRemote(); 203 | await anotherRepo2PushSomeFiles(); 204 | // it is equal until we fetch the latest remote 205 | await fetchRemote(dir, defaultGitInfo.remote); 206 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('behind'); 207 | await expect(async () => { 208 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 209 | }).rejects.toThrow(new AssumeSyncError('behind')); 210 | }); 211 | 212 | test('diverged after modify both remote and local', async () => { 213 | // repo2 modify the remote, make us behind 214 | await createAndSyncRepo2ToRemote(); 215 | await anotherRepo2PushSomeFiles(); 216 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 217 | 218 | // if use same file and same commit message, it will be equal than diverged in the end (?), not, it will just be diverged, at least tested in windows. 219 | await addSomeFiles(dir); 220 | await addAndCommitUsingDugite( 221 | dir, 222 | async () => { 223 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('equal'); 224 | }, 225 | 'some different commit message', 226 | ); 227 | // not latest remote data, so we thought we are ahead 228 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('ahead'); 229 | 230 | // it is equal until we fetch the latest remote 231 | await fetchRemote(dir, defaultGitInfo.remote); 232 | expect(await getSyncState(dir, defaultGitInfo.branch, defaultGitInfo.remote)).toBe('diverged'); 233 | await expect(async () => { 234 | await assumeSync(dir, defaultGitInfo.branch, defaultGitInfo.remote); 235 | }).rejects.toThrow(new AssumeSyncError('diverged')); 236 | }); 237 | }); 238 | }); 239 | }); 240 | 241 | describe('getGitRepositoryState', () => { 242 | test('normal git state', async () => { 243 | expect(await getGitRepositoryState(dir)).toBe(''); 244 | 245 | await addSomeFiles(dir); 246 | await addAndCommitUsingDugite(); 247 | expect(await getGitRepositoryState(dir)).toBe(''); 248 | }); 249 | 250 | test("'when no git, it say NOGIT", async () => { 251 | await fs.remove(gitDirectory); 252 | expect(await getGitRepositoryState(dir)).toBe('NOGIT'); 253 | }); 254 | 255 | test('dirty when there are some files', async () => { 256 | await addSomeFiles(dir); 257 | expect(await getGitRepositoryState(dir)).toBe('|DIRTY'); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /src/inspect.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'dugite'; 2 | import fs from 'fs-extra'; 3 | import { compact } from 'lodash'; 4 | import path from 'path'; 5 | import url from 'url'; 6 | import { AssumeSyncError, CantSyncGitNotInitializedError } from './errors'; 7 | import { GitStep, ILogger } from './interface'; 8 | import { toGitStringResult } from './utils'; 9 | 10 | const gitEscapeToEncodedUri = (str: string): string => str.replaceAll(/\\(\d{3})/g, (_: unknown, $1: string) => `%${Number.parseInt($1, 8).toString(16)}`); 11 | const decodeGitEscape = (rawString: string): string => decodeURIComponent(gitEscapeToEncodedUri(rawString)); 12 | 13 | export interface ModifiedFileList { 14 | filePath: string; 15 | fileRelativePath: string; 16 | type: string; 17 | } 18 | /** 19 | * Get modified files and modify type in a folder 20 | * @param {string} wikiFolderPath location to scan git modify state 21 | */ 22 | export async function getModifiedFileList(wikiFolderPath: string): Promise { 23 | const { stdout } = toGitStringResult(await exec(['status', '--porcelain'], wikiFolderPath)); 24 | const stdoutLines = stdout.split('\n'); 25 | const nonEmptyLines = compact(stdoutLines); 26 | const statusMatrixLines = compact(nonEmptyLines.map((line) => /^\s?(\?\?|[ACMR]|[ACMR][DM])\s?(\S+.*\S+)$/.exec(line))).filter( 27 | ([_, type, fileRelativePath]) => type !== undefined && fileRelativePath !== undefined, 28 | ) as unknown as Array<[unknown, string, string]>; 29 | return statusMatrixLines 30 | .map(([_, type, rawFileRelativePath]) => { 31 | /** 32 | * If filename contains Chinese, it will becomes: 33 | * ```js 34 | * fileRelativePath: "\"tiddlers/\\346\\226\\260\\346\\235\\241\\347\\233\\256.tid\""` 35 | * ``` 36 | * which is actually `"tiddlers/\\346\\226\\260\\346\\235\\241\\347\\233\\256.tid"` (if you try to type it in the console manually). If you console log it, it will become 37 | * ```js 38 | * > temp1[1].fileRelativePath 39 | * '"tiddlers/\346\226\260\346\235\241\347\233\256.tid"' 40 | * ``` 41 | * 42 | * So simply `decodeURIComponent(escape` will work on `tiddlers/\346\226\260\346\235\241\347\233\256.tid` (the logged string), but not on `tiddlers/\\346\\226\\260\\346\\235\\241\\347\\233\\256.tid` (the actual string). 43 | * So how to transform actual string to logged string? Answer is `eval()` it. But we have to check is there any evil script use `;` or `,` mixed into the filename. 44 | * 45 | * But actually those 346 226 are in radix 8 , if we transform it to radix 16 and add prefix % we can make it uri component. 46 | * And it should not be parsed in groups of three, because only the CJK between 0x0800 - 0xffff are encoded into three bytes; so we should just replace all the \\\d{3} with hexadecimal, and then give it to the decodeURIComponent to parse. 47 | */ 48 | const isSafeUtf8UnescapedString = rawFileRelativePath.startsWith('"') && rawFileRelativePath.endsWith('"') && !rawFileRelativePath.includes(';') && 49 | !rawFileRelativePath.includes(','); 50 | const fileRelativePath = isSafeUtf8UnescapedString ? decodeGitEscape(rawFileRelativePath).replace(/^"/, '').replace(/"$/, '') : rawFileRelativePath; 51 | return { 52 | type, 53 | fileRelativePath, 54 | filePath: path.normalize(path.join(wikiFolderPath, fileRelativePath)), 55 | }; 56 | }) 57 | .sort((item, item2) => item.fileRelativePath.localeCompare(item2.fileRelativePath, 'zh')); 58 | } 59 | 60 | /** 61 | * Inspect git's remote url from folder's .git config 62 | * @param dir wiki folder path, git folder to inspect 63 | * @returns remote url, without `'.git'` 64 | * @example ```ts 65 | const githubRepoUrl = await getRemoteUrl(directory); 66 | const gitUrlWithOutCredential = getGitUrlWithOutCredential(githubRepoUrl); 67 | await exec(['remote', 'set-url', 'origin', gitUrlWithOutCredential], directory); 68 | ``` 69 | */ 70 | export async function getRemoteUrl(dir: string, remoteName: string): Promise { 71 | // Use git remote get-url to get the remote URL 72 | const result = toGitStringResult(await exec(['remote', 'get-url', remoteName], dir)); 73 | if (result.exitCode === 0 && result.stdout.trim().length > 0) { 74 | return result.stdout.trim(); 75 | } 76 | 77 | // Fallback: try to get the first remote if the specified one doesn't exist 78 | const remotesResult = toGitStringResult(await exec(['remote'], dir)); 79 | if (remotesResult.exitCode === 0) { 80 | const remotes = remotesResult.stdout.trim().split('\n').filter(r => r.length > 0); 81 | const firstRemote = remotes[0]; 82 | if (firstRemote) { 83 | const firstRemoteResult = toGitStringResult(await exec(['remote', 'get-url', firstRemote], dir)); 84 | if (firstRemoteResult.exitCode === 0) { 85 | return firstRemoteResult.stdout.trim(); 86 | } 87 | } 88 | } 89 | 90 | return ''; 91 | } 92 | 93 | /** 94 | * Get the Github Repo Name, which is similar to "linonetwo/wiki", that is the string after "https://github.com/", so we basically just get the pathname of URL. 95 | * @param remoteUrl full github repository url or other repository url 96 | * @returns 97 | */ 98 | export function getRemoteRepoName(remoteUrl: string): string | undefined { 99 | let wikiRepoName = new url.URL(remoteUrl).pathname; 100 | if (wikiRepoName.startsWith('/')) { 101 | // deepcode ignore GlobalReplacementRegex: change only the first match 102 | wikiRepoName = wikiRepoName.replace('/', ''); 103 | } 104 | if (wikiRepoName.length > 0) { 105 | return wikiRepoName; 106 | } 107 | return undefined; 108 | } 109 | 110 | /** 111 | * See if there is any file not being committed 112 | * @param {string} wikiFolderPath repo path to test 113 | * @example ```ts 114 | if (await haveLocalChanges(dir)) { 115 | // ... do commit and push 116 | ``` 117 | */ 118 | export async function haveLocalChanges(wikiFolderPath: string): Promise { 119 | const { stdout } = toGitStringResult(await exec(['status', '--porcelain'], wikiFolderPath)); 120 | const matchResult = stdout.match(/^(\?\?|[ACMR] |[ ACMR][DM])*/gm); 121 | if (!matchResult) { 122 | return false; 123 | } 124 | return matchResult.some((entry) => Boolean(entry)); 125 | } 126 | 127 | /** 128 | * Get "master" or "main" from git repo 129 | * 130 | * https://github.com/simonthum/git-sync/blob/31cc140df2751e09fae2941054d5b61c34e8b649/git-sync#L228-L232 131 | * @param wikiFolderPath 132 | */ 133 | export async function getDefaultBranchName(wikiFolderPath: string): Promise { 134 | try { 135 | const { stdout } = toGitStringResult(await exec(['rev-parse', '--abbrev-ref', 'HEAD'], wikiFolderPath)); 136 | const [branchName] = stdout.split('\n'); 137 | // don't return empty string, so we can use ?? syntax 138 | if (branchName === '') { 139 | return undefined; 140 | } 141 | return branchName; 142 | } catch { 143 | /** 144 | * Catch "Unable to find path to repository on disk." 145 | at node_modules/dugite/lib/git-process.ts:226:29 146 | */ 147 | return undefined; 148 | } 149 | } 150 | 151 | export type SyncState = 'noUpstreamOrBareUpstream' | 'equal' | 'ahead' | 'behind' | 'diverged'; 152 | /** 153 | * determine sync state of repository, i.e. how the remote relates to our HEAD 154 | * 'ahead' means our local state is ahead of remote, 'behind' means local state is behind of the remote 155 | * @param dir repo path to test 156 | */ 157 | export async function getSyncState(dir: string, defaultBranchName: string, remoteName: string, logger?: ILogger): Promise { 158 | const logDebug = (message: string, step: GitStep): unknown => logger?.debug?.(message, { functionName: 'getSyncState', step, dir }); 159 | const logProgress = (step: GitStep): unknown => 160 | logger?.info?.(step, { 161 | functionName: 'getSyncState', 162 | step, 163 | dir, 164 | }); 165 | logProgress(GitStep.CheckingLocalSyncState); 166 | remoteName = remoteName ?? (await getRemoteName(dir, defaultBranchName)); 167 | const gitArgs = ['rev-list', '--count', '--left-right', `${remoteName}/${defaultBranchName}...HEAD`]; 168 | const { stdout, stderr } = toGitStringResult(await exec(gitArgs, dir)); 169 | logDebug(`Checking sync state with upstream, command: \`git ${gitArgs.join(' ')}\` , stdout:\n${stdout}\n(stdout end)`, GitStep.CheckingLocalSyncState); 170 | if (stderr.length > 0) { 171 | logDebug(`Have problem checking sync state with upstream,stderr:\n${stderr}\n(stderr end)`, GitStep.CheckingLocalSyncState); 172 | } 173 | if (stdout === '') { 174 | return 'noUpstreamOrBareUpstream'; 175 | } 176 | /** 177 | * checks for the output 0 0, which means there are no differences between the local and remote branches. If this is the case, the function returns 'equal'. 178 | */ 179 | if (/0\t0/.exec(stdout) !== null) { 180 | return 'equal'; 181 | } 182 | /** 183 | * The pattern /0\t\d+/ checks if there are commits on the current HEAD that are not on the remote branch (e.g., 0 2). If this pattern matches, the function returns 'ahead'. 184 | */ 185 | if (/0\t\d+/.exec(stdout) !== null) { 186 | return 'ahead'; 187 | } 188 | /** 189 | * The pattern /\d+\t0/ checks if there are commits on the remote branch that are not on the current HEAD (e.g., 2 0). If this pattern matches, the function returns 'behind'. 190 | */ 191 | if (/\d+\t0/.exec(stdout) !== null) { 192 | return 'behind'; 193 | } 194 | /** 195 | * If none of these patterns match, the function returns 'diverged'. For example, the output `1 1` will indicates that there is one commit on the origin/main branch that is not on your current HEAD, and also one commit on your current HEAD that is not on the origin/main branch. 196 | */ 197 | return 'diverged'; 198 | } 199 | 200 | export async function assumeSync(wikiFolderPath: string, defaultBranchName: string, remoteName: string, logger?: ILogger): Promise { 201 | const syncState = await getSyncState(wikiFolderPath, defaultBranchName, remoteName, logger); 202 | if (syncState === 'equal') { 203 | return; 204 | } 205 | throw new AssumeSyncError(syncState); 206 | } 207 | 208 | /** 209 | * get various repo state in string format 210 | * @param wikiFolderPath repo path to check 211 | * @returns gitState 212 | * // TODO: use template literal type to get exact type of git state 213 | */ 214 | export async function getGitRepositoryState(wikiFolderPath: string, logger?: ILogger): Promise { 215 | if (!(await hasGit(wikiFolderPath))) { 216 | return 'NOGIT'; 217 | } 218 | const gitDirectory = await getGitDirectory(wikiFolderPath, logger); 219 | const statExists = async (segments: string[], method: 'isFile' | 'isDirectory'): Promise => { 220 | try { 221 | const stats = await fs.lstat(path.join(gitDirectory, ...segments)); 222 | if (method === 'isFile') { 223 | return stats.isFile(); 224 | } 225 | return stats.isDirectory(); 226 | } catch { 227 | return false; 228 | } 229 | }; 230 | const [isRebaseI, isRebaseM, isAMRebase, isMerging, isCherryPicking, isBisecting] = await Promise.all([ 231 | statExists(['rebase-merge', 'interactive'], 'isFile'), 232 | statExists(['rebase-merge'], 'isDirectory'), 233 | statExists(['rebase-apply'], 'isDirectory'), 234 | statExists(['MERGE_HEAD'], 'isFile'), 235 | statExists(['CHERRY_PICK_HEAD'], 'isFile'), 236 | statExists(['BISECT_LOG'], 'isFile'), 237 | ]); 238 | let result = ''; 239 | if (isRebaseI) { 240 | result += 'REBASE-i'; 241 | } else if (isRebaseM) { 242 | result += 'REBASE-m'; 243 | } else { 244 | if (isAMRebase) { 245 | result += 'AM/REBASE'; 246 | } 247 | if (isMerging) { 248 | result += 'MERGING'; 249 | } 250 | if (isCherryPicking) { 251 | result += 'CHERRY-PICKING'; 252 | } 253 | if (isBisecting) { 254 | result += 'BISECTING'; 255 | } 256 | } 257 | const isBareResult = toGitStringResult(await exec(['rev-parse', '--is-bare-repository', wikiFolderPath], wikiFolderPath)); 258 | result += isBareResult.stdout.startsWith('true') ? '|BARE' : ''; 259 | 260 | /* if ((await exec(['rev-parse', '--is-inside-work-tree', wikiFolderPath], wikiFolderPath)).stdout.startsWith('true')) { 261 | const { exitCode } = await exec(['diff', '--no-ext-diff', '--quiet', '--exit-code'], wikiFolderPath); 262 | // 1 if there were differences and 0 means no differences. 263 | if (exitCode !== 0) { 264 | result += '|DIRTY'; 265 | } 266 | } */ 267 | // previous above `git diff --no-ext-diff --quiet --exit-code` logic from git-sync script can only detect if an existed file changed, can't detect newly added file, so we use `haveLocalChanges` instead 268 | if (await haveLocalChanges(wikiFolderPath)) { 269 | result += '|DIRTY'; 270 | } 271 | 272 | return result; 273 | } 274 | 275 | /** 276 | * echo the git dir 277 | * @param dir repo path 278 | */ 279 | export async function getGitDirectory(dir: string, logger?: ILogger): Promise { 280 | const logDebug = (message: string, step: GitStep): unknown => logger?.debug?.(message, { functionName: 'getGitDirectory', step, dir }); 281 | const logProgress = (step: GitStep): unknown => 282 | logger?.info?.(step, { 283 | functionName: 'getGitDirectory', 284 | step, 285 | dir, 286 | }); 287 | 288 | logProgress(GitStep.CheckingLocalGitRepoSanity); 289 | const { stdout, stderr } = toGitStringResult(await exec(['rev-parse', '--is-inside-work-tree', dir], dir)); 290 | if (stderr.length > 0) { 291 | logDebug(stderr, GitStep.CheckingLocalGitRepoSanity); 292 | throw new CantSyncGitNotInitializedError(dir); 293 | } 294 | if (stdout.startsWith('true')) { 295 | const gitDirResult = toGitStringResult(await exec(['rev-parse', '--git-dir', dir], dir)); 296 | const gitPathParts = compact(gitDirResult.stdout.split('\n')); 297 | const gitPath2 = gitPathParts[0]; 298 | const gitPath1 = gitPathParts[1]; 299 | if (gitPath2 !== undefined && gitPath1 !== undefined) { 300 | return path.resolve(gitPath1, gitPath2); 301 | } 302 | } 303 | throw new CantSyncGitNotInitializedError(dir); 304 | } 305 | 306 | /** 307 | * Check if dir has `.git`. 308 | * @param dir folder that may contains a git 309 | * @param strict if is true, then dir should be the root of the git repo. Default is true 310 | * @returns 311 | */ 312 | export async function hasGit(dir: string, strict = true): Promise { 313 | try { 314 | const resultDir = await getGitDirectory(dir); 315 | if (strict && path.dirname(resultDir) !== dir) { 316 | return false; 317 | } 318 | } catch (error) { 319 | if (error instanceof CantSyncGitNotInitializedError) { 320 | return false; 321 | } 322 | } 323 | return true; 324 | } 325 | 326 | /** 327 | * get things like "origin" 328 | * 329 | * https://github.com/simonthum/git-sync/blob/31cc140df2751e09fae2941054d5b61c34e8b649/git-sync#L238-L257 330 | */ 331 | export async function getRemoteName(dir: string, branch: string): Promise { 332 | let { stdout } = toGitStringResult(await exec(['config', '--get', `branch.${branch}.pushRemote`], dir)); 333 | if (stdout.trim()) { 334 | return stdout.trim(); 335 | } 336 | ({ stdout } = toGitStringResult(await exec(['config', '--get', `remote.pushDefault`], dir))); 337 | if (stdout.trim()) { 338 | return stdout.trim(); 339 | } 340 | ({ stdout } = toGitStringResult(await exec(['config', '--get', `branch.${branch}.remote`], dir))); 341 | if (stdout.trim()) { 342 | return stdout.trim(); 343 | } 344 | return 'origin'; 345 | } 346 | 347 | /** 348 | * Check if there are stale git lock files and optionally remove them. 349 | * Lock files can be left behind if git operations are interrupted. 350 | * 351 | * @param dir The git repository directory 352 | * @param logger Optional logger for debugging 353 | * @returns Array of lock file paths that were found 354 | */ 355 | export async function checkGitLockFiles(dir: string, logger?: ILogger): Promise { 356 | const logDebug = (message: string): unknown => 357 | logger?.debug(message, { 358 | functionName: 'checkGitLockFiles', 359 | step: GitStep.CheckingLocalGitRepoSanity, 360 | dir, 361 | }); 362 | 363 | try { 364 | const gitDir = await getGitDirectory(dir); 365 | const lockFiles = [ 366 | path.join(gitDir, 'index.lock'), 367 | path.join(gitDir, 'HEAD.lock'), 368 | path.join(gitDir, 'refs', 'heads', '*.lock'), 369 | path.join(gitDir, 'refs', 'remotes', '*.lock'), 370 | ]; 371 | 372 | const foundLockFiles: string[] = []; 373 | 374 | for (const lockPattern of lockFiles) { 375 | if (lockPattern.includes('*')) { 376 | // Handle wildcard patterns - check parent directory 377 | const parentDir = path.dirname(lockPattern); 378 | if (await fs.pathExists(parentDir)) { 379 | const files = await fs.readdir(parentDir); 380 | for (const file of files) { 381 | if (file.endsWith('.lock')) { 382 | const fullPath = path.join(parentDir, file); 383 | foundLockFiles.push(fullPath); 384 | logDebug(`Found lock file: ${fullPath}`); 385 | } 386 | } 387 | } 388 | } else { 389 | // Direct path check 390 | if (await fs.pathExists(lockPattern)) { 391 | foundLockFiles.push(lockPattern); 392 | logDebug(`Found lock file: ${lockPattern}`); 393 | } 394 | } 395 | } 396 | 397 | return foundLockFiles; 398 | } catch (error) { 399 | logDebug(`Error checking lock files: ${(error as Error).message}`); 400 | return []; 401 | } 402 | } 403 | 404 | /** 405 | * Remove stale git lock files that may block git operations. 406 | * This should only be called when you're sure no other git operations are running. 407 | * 408 | * @param dir The git repository directory 409 | * @param logger Optional logger for debugging 410 | * @returns Number of lock files removed 411 | */ 412 | export async function removeGitLockFiles(dir: string, logger?: ILogger): Promise { 413 | const logDebug = (message: string): unknown => 414 | logger?.debug(message, { 415 | functionName: 'removeGitLockFiles', 416 | step: GitStep.CheckingLocalGitRepoSanity, 417 | dir, 418 | }); 419 | const logWarn = (message: string): unknown => 420 | logger?.warn?.(message, { 421 | functionName: 'removeGitLockFiles', 422 | step: GitStep.CheckingLocalGitRepoSanity, 423 | dir, 424 | }); 425 | 426 | const lockFiles = await checkGitLockFiles(dir, logger); 427 | let removedCount = 0; 428 | 429 | for (const lockFile of lockFiles) { 430 | try { 431 | await fs.remove(lockFile); 432 | removedCount++; 433 | logWarn(`Removed stale lock file: ${lockFile}`); 434 | } catch (error) { 435 | logDebug(`Failed to remove lock file ${lockFile}: ${(error as Error).message}`); 436 | } 437 | } 438 | 439 | if (removedCount > 0) { 440 | logWarn(`Removed ${removedCount} stale git lock file(s)`); 441 | } 442 | 443 | return removedCount; 444 | } 445 | --------------------------------------------------------------------------------