kahole / edamagit

Magit for VSCode

Home Page:https://marketplace.visualstudio.com/items?itemName=kahole.magit

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Some commands fails on VSCode Insiders

unexge opened this issue · comments

commented

Some commands like jumping to a commit, staging a file, switching branches etc. fails on the latest build of VSCode Insiders with the Cannot read properties of undefined (reading 'exec') error.

Screenshot 2022-11-30 at 17 50 55

I've tried to debug the issue and I think the problem is: we are using a private variable from VSCode Git API in gitRun and with microsoft/vscode#167269 it started to get mangled.

You can see private variable repository mangled to J for example:
Screenshot 2022-11-30 at 17 53 54

commented

I couldn't find a way to execute raw Git commands through the public API. I think it'd be very big refactor to convert raw commands to public API calls. I've tried to copy related functions from VSCode source, it works, but it's not nice 🤷

copy related functions from VSCode to edamagit

diff --git a/package-lock.json b/package-lock.json
index 9321876..fba3802 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,16 +6,17 @@
   "packages": {
     "": {
       "name": "magit",
-      "version": "0.6.34",
+      "version": "0.6.35",
       "license": "MIT",
       "dependencies": {
+        "@vscode/iconv-lite-umd": "0.7.0",
         "date-fns": "^2.16.1",
         "jsonc-parser": "^3.0.0"
       },
       "devDependencies": {
         "@types/glob": "^7.1.3",
         "@types/mocha": "^8.2.0",
-        "@types/node": "^14.14.14",
+        "@types/node": "16.x",
         "@types/vscode": "^1.50.0",
         "@typescript-eslint/eslint-plugin": "^4.10.0",
         "@typescript-eslint/parser": "^4.10.0",
@@ -237,9 +238,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "14.14.14",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.14.tgz",
-      "integrity": "sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ==",
+      "version": "16.18.4",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.4.tgz",
+      "integrity": "sha512-9qGjJ5GyShZjUfx2ArBIGM+xExdfLvvaCyQR0t6yRXKPcWCVYF/WemtX/uIU3r7FYECXRXkIiw2Vnhn6y8d+pw==",
       "dev": true
     },
     "node_modules/@types/vscode": {
@@ -435,6 +436,11 @@
         "url": "https://opencollective.com/typescript-eslint"
       }
     },
+    "node_modules/@vscode/iconv-lite-umd": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz",
+      "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg=="
+    },
     "node_modules/@webassemblyjs/ast": {
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.1.tgz",
@@ -4250,9 +4256,9 @@
       "dev": true
     },
     "@types/node": {
-      "version": "14.14.14",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.14.tgz",
-      "integrity": "sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ==",
+      "version": "16.18.4",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.4.tgz",
+      "integrity": "sha512-9qGjJ5GyShZjUfx2ArBIGM+xExdfLvvaCyQR0t6yRXKPcWCVYF/WemtX/uIU3r7FYECXRXkIiw2Vnhn6y8d+pw==",
       "dev": true
     },
     "@types/vscode": {
@@ -4366,6 +4372,11 @@
         "eslint-visitor-keys": "^2.0.0"
       }
     },
+    "@vscode/iconv-lite-umd": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz",
+      "integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg=="
+    },
     "@webassemblyjs/ast": {
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.1.tgz",
diff --git a/package.json b/package.json
index 4a75f45..85c2f4f 100644
--- a/package.json
+++ b/package.json
@@ -737,7 +737,7 @@
   "devDependencies": {
     "@types/glob": "^7.1.3",
     "@types/mocha": "^8.2.0",
-    "@types/node": "^14.14.14",
+    "@types/node": "16.x",
     "@types/vscode": "^1.50.0",
     "@typescript-eslint/eslint-plugin": "^4.10.0",
     "@typescript-eslint/parser": "^4.10.0",
@@ -753,6 +753,7 @@
   },
   "dependencies": {
     "date-fns": "^2.16.1",
-    "jsonc-parser": "^3.0.0"
+    "jsonc-parser": "^3.0.0",
+    "@vscode/iconv-lite-umd": "0.7.0"
   }
-}
+}
\ No newline at end of file
diff --git a/src/common/gitApiExtensions.ts b/src/common/gitApiExtensions.ts
deleted file mode 100644
index e6993ec..0000000
--- a/src/common/gitApiExtensions.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import * as cp from 'child_process';
-
-// This refers to Repository in
-// vscode/extension/git/src/git.ts
-interface BaseGitRepository {
-  exec?(args: string[], options?: SpawnOptions): Promise<IExecutionResult<string>>;
-}
-
-// This refers to the Repository in
-// vscode/extension/git/src/repository.ts
-interface BaseRepository {
-  repository: BaseGitRepository;
-}
-
-// This refers to ApiRepository from
-// vscode/extension/git/src/api/api1.ts
-declare module '../typings/git' {
-  export interface Repository {
-    // Breaking change in VSCode: https://github.com/microsoft/vscode/pull/154555/files#diff-b7c16e46aefbf6182f8be03b099e5c407da09bd345ff2908abddd6bfe90c34aaL65-R65
-    // Going from this
-    readonly _repository: BaseRepository;
-    // to this:
-    readonly repository: BaseRepository;
-  }
-}
-
-// types from /extensions/git/src/git.ts
-
-export interface IExecutionResult<T extends string | Buffer> {
-  exitCode: number;
-  stdout: T;
-  stderr: string;
-}
-
-export interface SpawnOptions extends cp.SpawnOptions {
-  input?: string;
-  encoding?: string;
-  log?: boolean;
-  // cancellationToken?: CancellationToken;
-  // onSpawn?: (childProcess: cp.ChildProcess) => void;
-}
\ No newline at end of file
diff --git a/src/typings/git.d.ts b/src/typings/git.d.ts
index 96101c4..36d9d36 100644
--- a/src/typings/git.d.ts
+++ b/src/typings/git.d.ts
@@ -3,7 +3,7 @@
  *  Licensed under the MIT License. See License.txt in the project root for license information.
  *--------------------------------------------------------------------------------------------*/
 
-import { Uri, Event, Disposable, ProviderResult } from 'vscode';
+import { Uri, Event, Disposable, ProviderResult, Command } from 'vscode';
 export { ProviderResult } from 'vscode';
 
 export interface Git {
@@ -137,6 +137,15 @@ export interface CommitOptions {
 	empty?: boolean;
 	noVerify?: boolean;
 	requireUserConfig?: boolean;
+	useEditor?: boolean;
+	verbose?: boolean;
+	/**
+	 * string    - execute the specified command after the commit operation
+	 * undefined - execute the command specified in git.postCommitCommand
+	 *             after the commit operation
+	 * null      - do not execute any command after the commit operation
+	 */
+	postCommitCommand?: string | null;
 }
 
 export interface FetchOptions {
@@ -173,6 +182,7 @@ export interface Repository {
 	getCommit(ref: string): Promise<Commit>;
 
 	add(paths: string[]): Promise<void>;
+	revert(paths: string[]): Promise<void>;
 	clean(paths: string[]): Promise<void>;
 
 	apply(patch: string, reverse?: boolean): Promise<void>;
@@ -250,6 +260,10 @@ export interface CredentialsProvider {
 	getCredentials(host: Uri): ProviderResult<Credentials>;
 }
 
+export interface PostCommitCommandsProvider {
+	getCommands(repository: Repository): Command[];
+}
+
 export interface PushErrorHandler {
 	handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise<boolean>;
 }
@@ -278,6 +292,7 @@ export interface API {
 	registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable;
 	registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
 	registerCredentialsProvider(provider: CredentialsProvider): Disposable;
+	registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable;
 	registerPushErrorHandler(handler: PushErrorHandler): Disposable;
 }
 
@@ -289,7 +304,7 @@ export interface GitExtension {
 	/**
 	 * Returns a specific API version.
 	 *
-	 * Throws error if git extension is disabled. You can listed to the
+	 * Throws error if git extension is disabled. You can listen to the
 	 * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event
 	 * to know when the extension becomes enabled/disabled.
 	 *
@@ -335,4 +350,7 @@ export const enum GitErrorCodes {
 	PatchDoesNotApply = 'PatchDoesNotApply',
 	NoPathFound = 'NoPathFound',
 	UnknownPath = 'UnknownPath',
+	EmptyCommitMessage = 'EmptyCommitMessage',
+	BranchFastForwardRejected = 'BranchFastForwardRejected',
+	TagConflict = 'TagConflict'
 }
\ No newline at end of file
diff --git a/src/utils/gitProcessLogger.ts b/src/utils/gitProcessLogger.ts
index c5c8f20..59d2165 100644
--- a/src/utils/gitProcessLogger.ts
+++ b/src/utils/gitProcessLogger.ts
@@ -1,6 +1,6 @@
 import { processLog } from '../extension';
 import { MagitProcessLogEntry } from '../models/magitProcessLogEntry';
-import { IExecutionResult } from '../common/gitApiExtensions';
+import { IExecutionResult } from './gitRawRunner';
 
 export default class GitProcessLogger {
 
diff --git a/src/utils/gitRawRunner.ts b/src/utils/gitRawRunner.ts
index 4445522..f3f15f2 100644
--- a/src/utils/gitRawRunner.ts
+++ b/src/utils/gitRawRunner.ts
@@ -1,7 +1,25 @@
-import { SpawnOptions } from '../common/gitApiExtensions';
-import { Repository } from '../typings/git';
+import * as cp from 'child_process';
+import { fileURLToPath } from 'url';
+import { Uri } from 'vscode';
+import * as iconv from '@vscode/iconv-lite-umd';
+import { gitApi } from '../extension';
+import { GitErrorCodes, Repository } from '../typings/git';
 import GitProcessLogger from './gitProcessLogger';
 
+export interface IExecutionResult<T extends string | Buffer> {
+  exitCode: number;
+  stdout: T;
+  stderr: string;
+}
+
+export interface SpawnOptions extends cp.SpawnOptions {
+  input?: string;
+  encoding?: string;
+  // log?: boolean;
+  // cancellationToken?: CancellationToken;
+  // onSpawn?: (childProcess: cp.ChildProcess) => void;
+}
+
 export enum LogLevel {
   None,
   Error,
@@ -9,16 +27,13 @@ export enum LogLevel {
 }
 
 export async function gitRun(repository: Repository, args: string[], spawnOptions?: SpawnOptions, logLevel = LogLevel.Detailed) {
-
   let logEntry;
   if (logLevel > LogLevel.None) {
     logEntry = GitProcessLogger.logGitCommand(args);
   }
 
   try {
-    // Protect against coming breaking change in VSCode: https://github.com/microsoft/vscode/pull/154555/files#diff-b7c16e46aefbf6182f8be03b099e5c407da09bd345ff2908abddd6bfe90c34aaL65-R65
-    const baseRepository = repository._repository ?? repository.repository;
-    let result = await baseRepository.repository.exec!(args, spawnOptions);
+    const result = await exec(repository.rootUri, args, spawnOptions || {});
 
     if (logLevel === LogLevel.Detailed && logEntry) {
       GitProcessLogger.logGitResult(result, logEntry);
@@ -31,4 +46,242 @@ export async function gitRun(repository: Repository, args: string[], spawnOption
     }
     throw error;
   }
+}
+
+async function exec(root: Uri, args: string[], options: SpawnOptions) {
+  const child = spawn(args, Object.assign({ cwd: root.fsPath }, options));
+  if (options.input) {
+    child.stdin!.end(options.input, 'utf8');
+  }
+  const bufferResult = await _exec(child);
+
+  let encoding = options.encoding || 'utf8';
+  encoding = iconv.encodingExists(encoding) ? encoding : 'utf8';
+
+  const result: IExecutionResult<string> = {
+    exitCode: bufferResult.exitCode,
+    stdout: iconv.decode(bufferResult.stdout, encoding),
+    stderr: bufferResult.stderr
+  };
+
+  if (bufferResult.exitCode) {
+    return Promise.reject<IExecutionResult<string>>(new GitError({
+      message: 'Failed to execute git',
+      stdout: result.stdout,
+      stderr: result.stderr,
+      exitCode: result.exitCode,
+      gitErrorCode: getGitErrorCode(result.stderr),
+      gitCommand: args[0],
+      gitArgs: args
+    }));
+  }
+
+  return result;
+
+}
+
+async function _exec(child: cp.ChildProcess): Promise<IExecutionResult<Buffer>> {
+  if (!child.stdout || !child.stderr) {
+    throw new GitError({ message: 'Failed to get stdout or stderr from git process.' });
+  }
+
+  const disposables: IDisposable[] = [];
+
+  const once = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void) => {
+    ee.once(name, fn);
+    disposables.push(toDisposable(() => ee.removeListener(name, fn)));
+  };
+
+  const on = (ee: NodeJS.EventEmitter, name: string, fn: (...args: any[]) => void) => {
+    ee.on(name, fn);
+    disposables.push(toDisposable(() => ee.removeListener(name, fn)));
+  };
+
+  const result = Promise.all<any>([
+    new Promise<number>((c, e) => {
+      once(child, 'error', cpErrorHandler(e));
+      once(child, 'exit', c);
+    }),
+    new Promise<Buffer>(c => {
+      const buffers: Buffer[] = [];
+      on(child.stdout!, 'data', (b: Buffer) => buffers.push(b));
+      once(child.stdout!, 'close', () => c(Buffer.concat(buffers)));
+    }),
+    new Promise<string>(c => {
+      const buffers: Buffer[] = [];
+      on(child.stderr!, 'data', (b: Buffer) => buffers.push(b));
+      once(child.stderr!, 'close', () => c(Buffer.concat(buffers).toString('utf8')));
+    })
+  ]) as Promise<[number, Buffer, string]>;
+
+  try {
+    const [exitCode, stdout, stderr] = await result;
+    return { exitCode, stdout, stderr };
+  } finally {
+    dispose(disposables);
+  }
+}
+
+function spawn(args: string[], options: SpawnOptions = {}): cp.ChildProcess {
+  const gitPath = gitApi.git.path;
+  if (!gitPath) {
+    throw new Error('git could not be found in the system.');
+  }
+
+  if (!options) {
+    options = {};
+  }
+
+  if (!options.stdio && !options.input) {
+    options.stdio = ['ignore', null, null]; // Unless provided, ignore stdin and leave default streams for stdout and stderr
+  }
+
+  options.env = Object.assign({}, process.env, options.env || {}, {
+    VSCODE_GIT_COMMAND: args[0],
+    LC_ALL: 'en_US.UTF-8',
+    LANG: 'en_US.UTF-8',
+    GIT_PAGER: 'cat'
+  });
+
+  const cwd = getCwd(options);
+  if (cwd) {
+    options.cwd = sanitizePath(cwd);
+  }
+
+  return cp.spawn(gitPath, args, options);
+}
+
+
+function getGitErrorCode(stderr: string): string | undefined {
+  if (/Another git process seems to be running in this repository|If no other git process is currently running/.test(stderr)) {
+    return GitErrorCodes.RepositoryIsLocked;
+  } else if (/Authentication failed/i.test(stderr)) {
+    return GitErrorCodes.AuthenticationFailed;
+  } else if (/Not a git repository/i.test(stderr)) {
+    return GitErrorCodes.NotAGitRepository;
+  } else if (/bad config file/.test(stderr)) {
+    return GitErrorCodes.BadConfigFile;
+  } else if (/cannot make pipe for command substitution|cannot create standard input pipe/.test(stderr)) {
+    return GitErrorCodes.CantCreatePipe;
+  } else if (/Repository not found/.test(stderr)) {
+    return GitErrorCodes.RepositoryNotFound;
+  } else if (/unable to access/.test(stderr)) {
+    return GitErrorCodes.CantAccessRemote;
+  } else if (/branch '.+' is not fully merged/.test(stderr)) {
+    return GitErrorCodes.BranchNotFullyMerged;
+  } else if (/Couldn't find remote ref/.test(stderr)) {
+    return GitErrorCodes.NoRemoteReference;
+  } else if (/A branch named '.+' already exists/.test(stderr)) {
+    return GitErrorCodes.BranchAlreadyExists;
+  } else if (/'.+' is not a valid branch name/.test(stderr)) {
+    return GitErrorCodes.InvalidBranchName;
+  } else if (/Please,? commit your changes or stash them/.test(stderr)) {
+    return GitErrorCodes.DirtyWorkTree;
+  }
+
+  return undefined;
+}
+
+function getCwd(options: SpawnOptions): string | undefined {
+  const cwd = options.cwd;
+  if (typeof cwd === 'undefined' || typeof cwd === 'string') {
+    return cwd;
+  }
+
+  if (cwd.protocol === 'file:') {
+    return fileURLToPath(cwd);
+  }
+
+  return undefined;
+}
+
+// https://github.com/microsoft/vscode/issues/89373
+// https://github.com/git-for-windows/git/issues/2478
+function sanitizePath(path: string): string {
+  return path.replace(/^([a-z]):\\/i, (_, letter) => `${letter.toUpperCase()}:\\`);
+}
+
+function cpErrorHandler(cb: (reason?: any) => void): (reason?: any) => void {
+  return err => {
+    if (/ENOENT/.test(err.message)) {
+      err = new GitError({
+        error: err,
+        message: 'Failed to execute git (ENOENT)',
+        gitErrorCode: GitErrorCodes.NotAGitRepository
+      });
+    }
+
+    cb(err);
+  };
+}
+
+
+export interface IDisposable {
+  dispose(): void;
+}
+
+export function dispose<T extends IDisposable>(disposables: T[]): T[] {
+  disposables.forEach(d => d.dispose());
+  return [];
+}
+
+export function toDisposable(dispose: () => void): IDisposable {
+  return { dispose };
+}
+
+
+export interface IGitErrorData {
+  error?: Error;
+  message?: string;
+  stdout?: string;
+  stderr?: string;
+  exitCode?: number;
+  gitErrorCode?: string;
+  gitCommand?: string;
+  gitArgs?: string[];
+}
+
+export class GitError {
+  error?: Error;
+  message: string;
+  stdout?: string;
+  stderr?: string;
+  exitCode?: number;
+  gitErrorCode?: string;
+  gitCommand?: string;
+  gitArgs?: string[];
+
+  constructor(data: IGitErrorData) {
+    if (data.error) {
+      this.error = data.error;
+      this.message = data.error.message;
+    } else {
+      this.error = undefined;
+      this.message = '';
+    }
+
+    this.message = this.message || data.message || 'Git error';
+    this.stdout = data.stdout;
+    this.stderr = data.stderr;
+    this.exitCode = data.exitCode;
+    this.gitErrorCode = data.gitErrorCode;
+    this.gitCommand = data.gitCommand;
+    this.gitArgs = data.gitArgs;
+  }
+
+  toString(): string {
+    let result = this.message + ' ' + JSON.stringify({
+      exitCode: this.exitCode,
+      gitErrorCode: this.gitErrorCode,
+      gitCommand: this.gitCommand,
+      stdout: this.stdout,
+      stderr: this.stderr
+    }, null, 2);
+
+    if (this.error) {
+      result += (<any>this.error).stack;
+    }
+
+    return result;
+  }
 }
\ No newline at end of file
diff --git a/src/utils/gitUtils.ts b/src/utils/gitUtils.ts
index b2c7cc3..b4752d2 100644
--- a/src/utils/gitUtils.ts
+++ b/src/utils/gitUtils.ts
@@ -1,6 +1,5 @@
 import { MagitRepository } from '../models/magitRepository';
-import { gitRun } from './gitRawRunner';
-import { IExecutionResult } from '../common/gitApiExtensions';
+import { gitRun, IExecutionResult } from './gitRawRunner';
 
 export default class GitUtils {
 

Private doesn't matter. It's not enforced at runtime. The private keyword has been there untouched for 5 years.
This is the interface we use to dig deep into the private APIs of vscode-git:

// This refers to Repository in
// vscode/extension/git/src/git.ts
interface BaseGitRepository {
  exec?(args: string[], options?: SpawnOptions): Promise<IExecutionResult<string>>;
}

// This refers to the Repository in
// vscode/extension/git/src/repository.ts
interface BaseRepository {
  repository: BaseGitRepository;
}

// This refers to ApiRepository from
// vscode/extension/git/src/api/api1.ts
declare module '../typings/git' {
  export interface Repository {
    // Breaking change in VSCode: https://github.com/microsoft/vscode/pull/154555/files#diff-b7c16e46aefbf6182f8be03b099e5c407da09bd345ff2908abddd6bfe90c34aaL65-R65
    // Going from this
    readonly _repository: BaseRepository;
    // to this:
    readonly repository: BaseRepository;
  }
}

I actually kind see any changes in the vscode main branch that would mess with this. Is that the right branch for insiders? I need to look closer at it or run some tests

commented

Yes I believe they release Insiders version every night from the main branch of https://github.com/microsoft/vscode. I've the latest Insiders version right now and it is built from c87fa19f7932cefa1abeac4dd85ade3983780e14. I'm not sure which one is the exact PR that started mangling the API we're using but It started to fail recently (couple days ago I guess) so it must be one of microsoft/vscode#167626 or microsoft/vscode#166126. I've also found this recent discussion about mangling private variables: microsoft/vscode-discussions#257

Thanks for the heads up! Edamagit will break badly when this is released.
Will work on fixing this properly

Private doesn't matter. It's not enforced at runtime. The private keyword has been there untouched for 5 years.

Notice that VS Code will start enforcing it at runtime: microsoft/vscode#168072

Exposing exec isn't API we'd like to support in the long run.

Yeah, realised when unexge posted that 👍🏻
Working on a fix that does not rely on private fields

Pushed fix now: v0.6.36, uses no private APIs. Absorbed a lot of the code from the vscode repo. The fix is not ideal, and I'm afraid stuff might break

The VSCode version that breaks edamagit =< 0.6.35 was recently released on the main channel.

Important

If you get the error Cannot read properties of undefined (reading 'exec') now.
Make sure you upgrade edamagit to 0.6.36, and it should go away