Skip to content

Commit

Permalink
feat: retry remote Git operations
Browse files Browse the repository at this point in the history
Remote Git operations may fail due to network or
the Git server being not available. In this case
retry up to 3 times with a fixed cooldown time of 1 second.

The following operations use retry:
fetch, push, checkout

Resolves: #41
  • Loading branch information
saitho committed Feb 12, 2023
1 parent 1b6c332 commit 3138c56
Show file tree
Hide file tree
Showing 2 changed files with 92 additions and 19 deletions.
50 changes: 50 additions & 0 deletions src/helpers/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,46 @@ describe("git", () => {
);
});

it("push on second try", async () => {
((execa as unknown) as jest.Mock)
.mockRejectedValueOnce({stderr: 'An error occurred'});
await subject.push(
'http://github.com/saitho/semantic-release-backmerge',
'develop',
false
);
expect(execa).toHaveBeenCalledTimes(2)
expect(execa).toHaveBeenCalledWith(
'git',
['push', 'http://github.com/saitho/semantic-release-backmerge', 'HEAD:develop'],
expect.objectContaining(execaOpts)
);

await subject.push(
'http://github.com/saitho/semantic-release-backmerge',
'develop',
true
);
expect(execa).toHaveBeenCalledWith(
'git',
['push', 'http://github.com/saitho/semantic-release-backmerge', 'HEAD:develop', '-f'],
expect.objectContaining(execaOpts)
);
});

it("fetch", async () => {
((execa as unknown) as jest.Mock)
.mockRejectedValueOnce({stderr: 'An error occurred'});
await subject.fetch();
expect(execa).toHaveBeenCalledTimes(2)
expect(execa).toHaveBeenCalledWith('git', ['fetch'], expect.objectContaining(execaOpts));
});

it("fetch on second try", async () => {
((execa as unknown) as jest.Mock)
.mockRejectedValueOnce({stderr: 'An error occurred'});
await subject.fetch();
expect(execa).toHaveBeenCalledTimes(2)
expect(execa).toHaveBeenCalledWith('git', ['fetch'], expect.objectContaining(execaOpts));
});

Expand Down Expand Up @@ -90,6 +128,18 @@ describe("git", () => {
);
});

it("checkout on second try", async () => {
((execa as unknown) as jest.Mock)
.mockRejectedValueOnce({stderr: 'An error occurred'});
await subject.checkout('develop');
expect(execa).toHaveBeenCalledTimes(2)
expect(execa).toHaveBeenCalledWith(
'git',
['checkout', '-B', 'develop'],
expect.objectContaining(execaOpts)
);
});

it("stash", async () => {
await subject.stash();
expect(execa).toHaveBeenCalledWith(
Expand Down
61 changes: 42 additions & 19 deletions src/helpers/git.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {MergeMode} from "../definitions/config.js";
import execa from "execa";
import execa, {ExecaReturnValue} from "execa";
import debugPkg from "debug";
const debug = debugPkg('semantic-release:backmerge');

Expand All @@ -13,16 +13,43 @@ export default class Git {
this.execaOpts = execaOpts;
}

protected runGitCommand(args: string[], isLocal = true, options: object = {}, retry = 0): Promise<ExecaReturnValue> {
const maxRetries = isLocal ? 0 : 3; // retry remote Git operations up to 3 times if they fail
return new Promise<ExecaReturnValue>(async (resolve, reject) => {
try {
const result = await execa('git', args, {...this.execaOpts, ...options})
resolve(result)
} catch (error) {
console.log('catch error')
console.log(error)
console.log(retry, maxRetries)

if (retry >= maxRetries) {
reject(error)
return
}
// Retry
retry++
console.log('Unable to connect to Git. Retrying in 1 second (' + retry + '/' + maxRetries + ').')
setTimeout(() => {
this.runGitCommand(args, isLocal, options, retry)
.then(resolve)
.catch(reject)
}, 1000)
}
});
}

/**
* Add a list of file to the Git index. `.gitignore` will be ignored.
*
* @param {Array<String>} files Array of files path to add to the index.
*/
async add(files: string[]) {
const shell = await execa(
'git',
const shell = await this.runGitCommand(
['add', '--force', '--ignore-errors', ...files],
{...this.execaOpts, reject: false}
true,
{reject: false}
);
debug('add file to git index', shell);
}
Expand All @@ -35,7 +62,7 @@ export default class Git {
* @throws {Error} if the commit failed.
*/
async commit(message: string) {
await execa('git', ['commit', '-m', message], this.execaOpts);
await this.runGitCommand(['commit', '-m', message]);
}

/**
Expand All @@ -44,7 +71,7 @@ export default class Git {
* @throws {Error} if the commit failed.
*/
async stash() {
await execa('git', ['stash'], this.execaOpts);
await this.runGitCommand(['stash']);
}

/**
Expand All @@ -53,7 +80,7 @@ export default class Git {
* @throws {Error} if the commit failed.
*/
async unstash() {
await execa('git', ['stash', 'pop'], this.execaOpts);
await this.runGitCommand(['stash', 'pop']);
}

/**
Expand All @@ -70,7 +97,7 @@ export default class Git {
if (forcePush) {
args.push('-f');
}
await execa('git', args, this.execaOpts);
await this.runGitCommand(args, false);
}


Expand All @@ -84,7 +111,7 @@ export default class Git {
if (url) {
args.push(url);
}
await execa('git', args, this.execaOpts);
await this.runGitCommand(args, false)
}

/**
Expand All @@ -93,11 +120,7 @@ export default class Git {
* @throws {Error} if the config failed.
*/
async configFetchAllRemotes() {
await execa(
'git',
['config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'],
this.execaOpts
);
await this.runGitCommand(['config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'], false)
}

/**
Expand All @@ -108,7 +131,7 @@ export default class Git {
* @throws {Error} if the checkout failed.
*/
async checkout(branch: string) {
await execa('git', ['checkout', '-B', branch], this.execaOpts);
await this.runGitCommand(['checkout', '-B', branch], false);
}

/**
Expand All @@ -117,8 +140,8 @@ export default class Git {
*/
getModifiedFiles(): Promise<string[]> {
return new Promise<string[]>(async (resolve, reject) => {
execa('git', ['status', '-s', '-uno'], this.execaOpts)
.then((result: { stdout: string; }) => {
this.runGitCommand(['status', '-s', '-uno'])
.then((result: ExecaReturnValue) => {
const lines = result.stdout.split('\n');
resolve( lines.filter((item: string) => item.length) );
})
Expand All @@ -134,7 +157,7 @@ export default class Git {
* @throws {Error} if the rebase failed.
*/
async rebase(branch: string) {
await execa('git', ['rebase', `origin/${branch}`], this.execaOpts);
await this.runGitCommand(['rebase', `origin/${branch}`]);
}

/**
Expand All @@ -151,6 +174,6 @@ export default class Git {
args.push('-X' + mergeMode)
}
args.push('origin/' + branch)
await execa('git', args, this.execaOpts);
await this.runGitCommand(args);
}
}

0 comments on commit 3138c56

Please sign in to comment.