Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion src/vs/platform/git/node/localGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,57 @@ export class LocalGitService implements ILocalGitService {

async pull(operationId: string, repoPath: string): Promise<boolean> {
const before = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim();
await this._exec(operationId, ['pull', '--ff-only'], repoPath);

try {
await this._exec(operationId, ['pull', '--ff-only'], repoPath);
} catch (err) {
this._logService.warn(`[LocalGitService] Fast-forward pull failed for ${repoPath}. Retrying after fetch.`);
await this._exec(operationId, ['fetch', '--prune'], repoPath);
Comment on lines +56 to +59

try {
await this._exec(operationId, ['pull', '--ff-only'], repoPath);
} catch (retryErr) {
const upstream = await this._getSafeHardResetTarget(operationId, repoPath);
if (!upstream) {
throw retryErr;
}

this._logService.warn(`[LocalGitService] Pull retries exhausted for ${repoPath}. Performing hard reset to ${upstream}.`);
await this._exec(operationId, ['reset', '--hard', upstream], repoPath);
}
}
Comment on lines +54 to +76
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will be fine as this code path isn't intended for use by the user.


const after = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim();
return before !== after;
}

private async _getSafeHardResetTarget(operationId: string, repoPath: string): Promise<string | undefined> {
const status = (await this._exec(operationId, ['status', '--porcelain'], repoPath)).trim();
if (status.length > 0) {
return undefined;
}

let upstream: string;
try {
upstream = (await this._exec(operationId, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], repoPath)).trim();
} catch {
return undefined;
}
Comment on lines +82 to +93

const behind = await this._revListCount(operationId, repoPath, 'HEAD', '@{u}');
const ahead = await this._revListCount(operationId, repoPath, '@{u}', 'HEAD');
if (ahead <= 0 || behind <= 0) {
return undefined;
}

return upstream;
}

private async _revListCount(operationId: string, repoPath: string, fromRef: string, toRef: string): Promise<number> {
const result = await this._exec(operationId, ['rev-list', '--count', `${fromRef}..${toRef}`], repoPath);
return Number(result.trim()) || 0;
}

async checkout(operationId: string, repoPath: string, treeish: string, detached?: boolean): Promise<void> {
const args = detached
? ['checkout', '--detach', treeish]
Expand Down
143 changes: 143 additions & 0 deletions src/vs/platform/git/test/node/localGitService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import * as cp from 'child_process';
import * as sinon from 'sinon';
import { NullLogService } from '../../../log/common/log.js';
import { LocalGitService } from '../../node/localGitService.js';

interface IExecFileExpectation {
args: string[];
stdout?: string;
stderr?: string;
error?: cp.ExecFileException;
}

function stubExecFile(expectations: IExecFileExpectation[]): void {
sinon.stub(cp, 'execFile').callsFake((command: unknown, args: unknown, _options: unknown, callback: unknown) => {
assert.strictEqual(command, 'git');

const expectation = expectations.shift();
assert.ok(expectation, `Unexpected git call: ${(args as string[]).join(' ')}`);
assert.deepStrictEqual(args, expectation.args);

const cb = callback as (error: cp.ExecFileException | null, stdout: string, stderr: string) => void;
cb(expectation.error ?? null, expectation.stdout ?? '', expectation.stderr ?? '');

return {} as cp.ChildProcess;
});
}

function createDivergedPullError(): cp.ExecFileException {
const error = new Error('fatal: Not possible to fast-forward, aborting.') as cp.ExecFileException & { stderr: string };
error.code = 128;
error.stderr = 'fatal: Not possible to fast-forward, aborting.';
return error;
}

suite('LocalGitService', () => {
teardown(() => {
sinon.restore();
});

test('pull runs ff-only for normal updates', async () => {
const expectations: IExecFileExpectation[] = [
{ args: ['rev-parse', 'HEAD'], stdout: 'aaaa\n' },
{ args: ['pull', '--ff-only'] },
{ args: ['rev-parse', 'HEAD'], stdout: 'bbbb\n' },
];
stubExecFile(expectations);

const service = new LocalGitService(new NullLogService());
const changed = await service.pull('test-op', 'C:\\repo');

assert.strictEqual(changed, true);
assert.strictEqual(expectations.length, 0);
});

test('pull recovers from diverged history by resetting to upstream', async () => {
const expectations: IExecFileExpectation[] = [
{ args: ['rev-parse', 'HEAD'], stdout: 'aaaa\n' },
{ args: ['pull', '--ff-only'], error: createDivergedPullError() },
{ args: ['fetch', '--prune'] },
{ args: ['pull', '--ff-only'], error: createDivergedPullError() },
{ args: ['status', '--porcelain'], stdout: '' },
{ args: ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], stdout: 'origin/main\n' },
{ args: ['rev-list', '--count', 'HEAD..@{u}'], stdout: '2\n' },
{ args: ['rev-list', '--count', '@{u}..HEAD'], stdout: '1\n' },
{ args: ['reset', '--hard', 'origin/main'] },
{ args: ['rev-parse', 'HEAD'], stdout: 'bbbb\n' },
];
stubExecFile(expectations);

const service = new LocalGitService(new NullLogService());
const changed = await service.pull('test-op', 'C:\\repo');

assert.strictEqual(changed, true);
assert.strictEqual(expectations.length, 0);
});

test('pull rejects hard reset recovery when working tree is dirty', async () => {
const expectations: IExecFileExpectation[] = [
{ args: ['rev-parse', 'HEAD'], stdout: 'aaaa\n' },
{ args: ['pull', '--ff-only'], error: createDivergedPullError() },
{ args: ['fetch', '--prune'] },
{ args: ['pull', '--ff-only'], error: createDivergedPullError() },
{ args: ['status', '--porcelain'], stdout: ' M package.json\n' },
];
stubExecFile(expectations);

const service = new LocalGitService(new NullLogService());

await assert.rejects(
() => service.pull('test-op', 'C:\\repo'),
/Not possible to fast-forward/
);
assert.strictEqual(expectations.length, 0);
});

test('pull rethrows errors after retries when repo is not diverged', async () => {
const pullError = new Error('fatal: Failed to pull') as cp.ExecFileException;
pullError.code = 128;

const expectations: IExecFileExpectation[] = [
{ args: ['rev-parse', 'HEAD'], stdout: 'aaaa\n' },
{ args: ['pull', '--ff-only'], error: pullError, stderr: 'fatal: Authentication failed' },
{ args: ['fetch', '--prune'] },
{ args: ['pull', '--ff-only'], error: pullError, stderr: 'fatal: Authentication failed' },
{ args: ['status', '--porcelain'], stdout: '' },
{ args: ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], stdout: 'origin/main\n' },
{ args: ['rev-list', '--count', 'HEAD..@{u}'], stdout: '1\n' },
{ args: ['rev-list', '--count', '@{u}..HEAD'], stdout: '0\n' },
];
stubExecFile(expectations);

const service = new LocalGitService(new NullLogService());

await assert.rejects(
() => service.pull('test-op', 'C:\\repo'),
/Failed to pull/
);
assert.strictEqual(expectations.length, 0);
});

test('pull succeeds on second ff-only attempt after fetch', async () => {
const expectations: IExecFileExpectation[] = [
{ args: ['rev-parse', 'HEAD'], stdout: 'aaaa\n' },
{ args: ['pull', '--ff-only'], error: createDivergedPullError() },
{ args: ['fetch', '--prune'] },
{ args: ['pull', '--ff-only'] },
{ args: ['rev-parse', 'HEAD'], stdout: 'bbbb\n' },
];
stubExecFile(expectations);

const service = new LocalGitService(new NullLogService());
const changed = await service.pull('test-op', 'C:\\repo');

assert.strictEqual(changed, true);
assert.strictEqual(expectations.length, 0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,64 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
} catch (err) {
this._logService.error(`[AgentPluginRepositoryService] Failed to update ${marketplace.displayLabel}:`, err);
if (!options?.silent) {
const primaryActions = [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => this._commandService.executeCommand('git.showOutput'))];

if (marketplace.kind !== MarketplaceReferenceKind.LocalFileUri) {
primaryActions.push(new Action('purgeAndRecloneMarketplace', localize('purgeAndRecloneMarketplace', "Purge Marketplace Cache and Reclone"), undefined, true, () => this._purgeAndRecloneMarketplace(marketplace, options?.marketplaceType, updateLabel)));
}
Comment on lines +178 to +183

this._notificationService.notify({
severity: Severity.Error,
message: localize('pullFailed', "Failed to update plugin '{0}': {1}", options?.failureLabel ?? updateLabel, err?.message ?? String(err)),
actions: {
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
this._commandService.executeCommand('git.showOutput');
})],
primary: primaryActions,
},
});
}
throw err;
}
}

private async _purgeAndRecloneMarketplace(marketplace: IMarketplaceReference, marketplaceType: MarketplaceType | undefined, label: string): Promise<void> {
if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri) {
return;
}

const repoDir = this.getRepositoryUri(marketplace, marketplaceType);
try {
await this._progressService.withProgress(
{
location: ProgressLocation.Notification,
title: localize('purgingMarketplace', "Purging plugin marketplace '{0}'...", marketplace.displayLabel),
cancellable: false,
},
async () => {
const exists = await this._fileService.exists(repoDir);
if (exists) {
await this._fileService.del(repoDir, { recursive: true });
}
await this.ensureRepository(marketplace, {
marketplaceType,
progressTitle: localize('recloningMarketplace', "Recloning plugin marketplace '{0}'...", marketplace.displayLabel),
failureLabel: label,
});
}
);

this._notificationService.info(localize('purgeMarketplaceSuccess', "Recloned plugin marketplace '{0}'. Try updating plugins again.", marketplace.displayLabel));
} catch (err) {
this._notificationService.notify({
severity: Severity.Error,
message: localize('purgeMarketplaceFailed', "Failed to purge plugin marketplace '{0}': {1}", marketplace.displayLabel, err?.message ?? String(err)),
actions: {
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
this._commandService.executeCommand('git.showOutput');
})],
},
});
}
}

private _getRepoCacheDirForReference(reference: IMarketplaceReference): URI {
return joinPath(this._cacheRoot, ...reference.cacheSegments);
}
Expand Down
Loading