'use strict';
|
|
import * as fs from 'fs';
|
|
import * as paths from 'path';
|
|
import {
|
|
ConfigurationChangeEvent,
|
|
Disposable,
|
|
Event,
|
|
EventEmitter,
|
|
Extension,
|
|
extensions,
|
|
ProgressLocation,
|
|
Range,
|
|
TextEditor,
|
|
Uri,
|
|
window,
|
|
WindowState,
|
|
workspace,
|
|
WorkspaceFolder,
|
|
WorkspaceFoldersChangeEvent,
|
|
} from 'vscode';
|
|
import { API as BuiltInGitApi, Repository as BuiltInGitRepository, GitExtension } from '../@types/git';
|
|
import { resetAvatarCache } from '../avatars';
|
|
import { BranchSorting, configuration, TagSorting } from '../configuration';
|
|
import { ContextKeys, DocumentSchemes, GlyphChars, setContext } from '../constants';
|
|
import { Container } from '../container';
|
|
import { setEnabled } from '../extension';
|
|
import {
|
|
Authentication,
|
|
BranchDateFormatting,
|
|
CommitDateFormatting,
|
|
Git,
|
|
GitAuthor,
|
|
GitBlame,
|
|
GitBlameCommit,
|
|
GitBlameLine,
|
|
GitBlameLines,
|
|
GitBlameParser,
|
|
GitBranch,
|
|
GitBranchParser,
|
|
GitBranchReference,
|
|
GitCommitType,
|
|
GitContributor,
|
|
GitDiff,
|
|
GitDiffFilter,
|
|
GitDiffHunkLine,
|
|
GitDiffParser,
|
|
GitDiffShortStat,
|
|
GitErrors,
|
|
GitFile,
|
|
GitLog,
|
|
GitLogCommit,
|
|
GitLogParser,
|
|
GitReference,
|
|
GitReflog,
|
|
GitRemote,
|
|
GitRemoteParser,
|
|
GitRevision,
|
|
GitStash,
|
|
GitStashParser,
|
|
GitStatus,
|
|
GitStatusFile,
|
|
GitStatusParser,
|
|
GitTag,
|
|
GitTagParser,
|
|
GitTree,
|
|
GitTreeParser,
|
|
PullRequest,
|
|
PullRequestDateFormatting,
|
|
PullRequestState,
|
|
Repository,
|
|
RepositoryChange,
|
|
RepositoryChangeEvent,
|
|
SearchPattern,
|
|
} from './git';
|
|
import { GitUri } from './gitUri';
|
|
import { LogCorrelationContext, Logger } from '../logger';
|
|
import { Messages } from '../messages';
|
|
import { GitReflogParser, GitShortLogParser } from './parsers/parsers';
|
|
import { RemoteProvider, RemoteProviderFactory, RemoteProviders, RichRemoteProvider } from './remotes/factory';
|
|
import { fsExists, isWindows } from './shell';
|
|
import {
|
|
Arrays,
|
|
debug,
|
|
Functions,
|
|
gate,
|
|
Iterables,
|
|
log,
|
|
Objects,
|
|
Promises,
|
|
Strings,
|
|
TernarySearchTree,
|
|
Versions,
|
|
} from '../system';
|
|
import { CachedBlame, CachedDiff, CachedLog, GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker';
|
|
import { vslsUriPrefixRegex } from '../vsls/vsls';
|
|
|
|
const emptyStr = '';
|
|
const slash = '/';
|
|
|
|
const RepoSearchWarnings = {
|
|
doesNotExist: /no such file or directory/i,
|
|
};
|
|
|
|
const doubleQuoteRegex = /"/g;
|
|
const driveLetterRegex = /(?<=^\/?)([a-zA-Z])(?=:\/)/;
|
|
const userConfigRegex = /^user\.(name|email) (.*)$/gm;
|
|
const mappedAuthorRegex = /(.+)\s<(.+)>/;
|
|
|
|
const emptyPromise: Promise<GitBlame | GitDiff | GitLog | undefined> = Promise.resolve(undefined);
|
|
const reflogCommands = ['merge', 'pull'];
|
|
|
|
const maxDefaultBranchWeight = 100;
|
|
const weightedDefaultBranches = new Map<string, number>([
|
|
['master', maxDefaultBranchWeight],
|
|
['main', 15],
|
|
['default', 10],
|
|
['develop', 5],
|
|
['development', 1],
|
|
]);
|
|
|
|
export class GitService implements Disposable {
|
|
private _onDidChangeRepositories = new EventEmitter<void>();
|
|
get onDidChangeRepositories(): Event<void> {
|
|
return this._onDidChangeRepositories.event;
|
|
}
|
|
|
|
private readonly _disposable: Disposable;
|
|
private readonly _repositoryTree: TernarySearchTree<string, Repository>;
|
|
private _repositoriesLoadingPromise: Promise<void> | undefined;
|
|
|
|
private readonly _branchesCache = new Map<string, GitBranch[]>();
|
|
private readonly _remotesWithApiProviderCache = new Map<string, GitRemote<RichRemoteProvider> | null>();
|
|
private readonly _tagsCache = new Map<string, GitTag[]>();
|
|
private readonly _trackedCache = new Map<string, boolean | Promise<boolean>>();
|
|
private readonly _userMapCache = new Map<string, { name?: string; email?: string } | null>();
|
|
|
|
constructor() {
|
|
this._repositoryTree = TernarySearchTree.forPaths();
|
|
|
|
this._disposable = Disposable.from(
|
|
window.onDidChangeWindowState(this.onWindowStateChanged, this),
|
|
workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this),
|
|
configuration.onDidChange(this.onConfigurationChanged, this),
|
|
Authentication.onDidChange(e => {
|
|
if (e.reason === 'connected') {
|
|
resetAvatarCache('failed');
|
|
}
|
|
this._remotesWithApiProviderCache.clear();
|
|
}),
|
|
);
|
|
this.onConfigurationChanged(configuration.initializingChangeEvent);
|
|
|
|
this._repositoriesLoadingPromise = this.onWorkspaceFoldersChanged();
|
|
}
|
|
|
|
dispose() {
|
|
this._repositoryTree.forEach(r => r.dispose());
|
|
this._branchesCache.clear();
|
|
this._remotesWithApiProviderCache.clear();
|
|
this._tagsCache.clear();
|
|
this._trackedCache.clear();
|
|
this._userMapCache.clear();
|
|
|
|
this._disposable.dispose();
|
|
}
|
|
|
|
@log()
|
|
static async initialize(): Promise<void> {
|
|
// Try to use the same git as the built-in vscode git extension
|
|
let gitPath;
|
|
const gitApi = await GitService.getBuiltInGitApi();
|
|
if (gitApi != null) {
|
|
gitPath = gitApi.git.path;
|
|
}
|
|
|
|
await Git.setOrFindGitPath(gitPath ?? configuration.getAny<string | string[]>('git.path'));
|
|
}
|
|
|
|
get readonly() {
|
|
return Container.vsls.readonly;
|
|
}
|
|
|
|
get useCaching() {
|
|
return Container.config.advanced.caching.enabled;
|
|
}
|
|
|
|
private onAnyRepositoryChanged(repo: Repository, e: RepositoryChangeEvent) {
|
|
if (e.changed(RepositoryChange.Stash, true)) return;
|
|
|
|
this._branchesCache.delete(repo.path);
|
|
if (e.changed(RepositoryChange.Remotes)) {
|
|
this._remotesWithApiProviderCache.clear();
|
|
}
|
|
this._tagsCache.delete(repo.path);
|
|
this._trackedCache.clear();
|
|
|
|
if (e.changed(RepositoryChange.Config)) {
|
|
this._userMapCache.delete(repo.path);
|
|
}
|
|
|
|
if (e.changed(RepositoryChange.Closed)) {
|
|
// Send a notification that the repositories changed
|
|
setImmediate(async () => {
|
|
await this.updateContext(this._repositoryTree);
|
|
|
|
this.fireRepositoriesChanged();
|
|
});
|
|
}
|
|
}
|
|
|
|
private onConfigurationChanged(e: ConfigurationChangeEvent) {
|
|
if (
|
|
configuration.changed(e, 'defaultDateFormat') ||
|
|
configuration.changed(e, 'defaultDateSource') ||
|
|
configuration.changed(e, 'defaultDateStyle')
|
|
) {
|
|
BranchDateFormatting.reset();
|
|
CommitDateFormatting.reset();
|
|
PullRequestDateFormatting.reset();
|
|
}
|
|
}
|
|
|
|
@debug()
|
|
private onWindowStateChanged(e: WindowState) {
|
|
if (e.focused) {
|
|
this._repositoryTree.forEach(r => r.resume());
|
|
} else {
|
|
this._repositoryTree.forEach(r => r.suspend());
|
|
}
|
|
}
|
|
|
|
private async onWorkspaceFoldersChanged(e?: WorkspaceFoldersChangeEvent) {
|
|
let initializing = false;
|
|
if (e == null) {
|
|
initializing = true;
|
|
e = {
|
|
added: workspace.workspaceFolders ?? [],
|
|
removed: [],
|
|
};
|
|
|
|
Logger.log(`Starting repository search in ${e.added.length} folders`);
|
|
}
|
|
|
|
for (const f of e.added) {
|
|
const { scheme } = f.uri;
|
|
if (scheme !== DocumentSchemes.File && scheme !== DocumentSchemes.Vsls) continue;
|
|
|
|
if (scheme === DocumentSchemes.Vsls) {
|
|
if (Container.vsls.isMaybeGuest) {
|
|
const guest = await Container.vsls.guest();
|
|
if (guest != null) {
|
|
const repositories = await guest.getRepositoriesInFolder(
|
|
f,
|
|
this.onAnyRepositoryChanged.bind(this),
|
|
);
|
|
for (const r of repositories) {
|
|
if (!this._repositoryTree.has(r.path)) {
|
|
this._repositoryTree.set(r.path, r);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Search for and add all repositories (nested and/or submodules)
|
|
const repositories = await this.repositorySearch(f);
|
|
for (const r of repositories) {
|
|
if (!this._repositoryTree.has(r.path)) {
|
|
this._repositoryTree.set(r.path, r);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const f of e.removed) {
|
|
const { fsPath, scheme } = f.uri;
|
|
if (scheme !== DocumentSchemes.File && scheme !== DocumentSchemes.Vsls) continue;
|
|
|
|
const repos = this._repositoryTree.findSuperstr(fsPath);
|
|
const reposToDelete =
|
|
repos != null
|
|
? // Since the filtered tree will have keys that are relative to the fsPath, normalize to the full path
|
|
[...Iterables.map<Repository, [Repository, string]>(repos, r => [r, r.path])]
|
|
: [];
|
|
|
|
// const filteredTree = this._repositoryTree.findSuperstr(fsPath);
|
|
// const reposToDelete =
|
|
// filteredTree != null
|
|
// ? // Since the filtered tree will have keys that are relative to the fsPath, normalize to the full path
|
|
// [
|
|
// ...Iterables.map<[Repository, string], [Repository, string]>(
|
|
// filteredTree.entries(),
|
|
// ([r, k]) => [r, path.join(fsPath, k)]
|
|
// )
|
|
// ]
|
|
// : [];
|
|
|
|
const repo = this._repositoryTree.get(fsPath);
|
|
if (repo != null) {
|
|
reposToDelete.push([repo, fsPath]);
|
|
}
|
|
|
|
for (const [r, k] of reposToDelete) {
|
|
this._repositoryTree.delete(k);
|
|
r.dispose();
|
|
}
|
|
}
|
|
|
|
await this.updateContext(this._repositoryTree);
|
|
|
|
if (!initializing) {
|
|
// Defer the event trigger enough to let everything unwind
|
|
setImmediate(() => this.fireRepositoriesChanged());
|
|
}
|
|
}
|
|
|
|
@log<GitService['repositorySearch']>({
|
|
args: false,
|
|
singleLine: true,
|
|
prefix: (context, folder) => `${context.prefix}(${folder.uri.fsPath})`,
|
|
exit: result =>
|
|
`returned ${result.length} repositories${
|
|
result.length !== 0 ? ` (${result.map(r => r.path).join(', ')})` : emptyStr
|
|
}`,
|
|
})
|
|
private async repositorySearch(folder: WorkspaceFolder): Promise<Repository[]> {
|
|
const cc = Logger.getCorrelationContext();
|
|
const { uri } = folder;
|
|
const depth = configuration.get('advanced', 'repositorySearchDepth', uri);
|
|
|
|
Logger.log(cc, `searching (depth=${depth})...`);
|
|
|
|
const repositories: Repository[] = [];
|
|
const anyRepoChangedFn = this.onAnyRepositoryChanged.bind(this);
|
|
|
|
const rootPath = await this.getRepoPathCore(uri.fsPath, true);
|
|
if (rootPath != null) {
|
|
Logger.log(cc, `found root repository in '${rootPath}'`);
|
|
repositories.push(new Repository(folder, rootPath, true, anyRepoChangedFn, !window.state.focused));
|
|
}
|
|
|
|
if (depth <= 0) return repositories;
|
|
|
|
// Get any specified excludes -- this is a total hack, but works for some simple cases and something is better than nothing :)
|
|
let excludes = {
|
|
...configuration.getAny<Record<string, boolean>>('files.exclude', uri, {}),
|
|
...configuration.getAny<Record<string, boolean>>('search.exclude', uri, {}),
|
|
};
|
|
|
|
const excludedPaths = [
|
|
...Iterables.filterMap(Objects.entries(excludes), ([key, value]) => {
|
|
if (!value) return undefined;
|
|
if (key.startsWith('**/')) return key.substring(3);
|
|
return key;
|
|
}),
|
|
];
|
|
|
|
excludes = excludedPaths.reduce((accumulator, current) => {
|
|
accumulator[current] = true;
|
|
return accumulator;
|
|
}, Object.create(null) as Record<string, boolean>);
|
|
|
|
let repoPaths;
|
|
try {
|
|
repoPaths = await this.repositorySearchCore(uri.fsPath, depth, excludes);
|
|
} catch (ex) {
|
|
const msg: string = ex?.toString() ?? emptyStr;
|
|
if (RepoSearchWarnings.doesNotExist.test(msg)) {
|
|
Logger.log(cc, `FAILED${msg ? ` Error: ${msg}` : emptyStr}`);
|
|
} else {
|
|
Logger.error(ex, cc, 'FAILED');
|
|
}
|
|
|
|
return repositories;
|
|
}
|
|
|
|
for (let p of repoPaths) {
|
|
p = paths.dirname(p);
|
|
// If we are the same as the root, skip it
|
|
if (Strings.normalizePath(p) === rootPath) continue;
|
|
|
|
Logger.log(cc, `searching in '${p}'...`);
|
|
|
|
const rp = await this.getRepoPathCore(p, true);
|
|
if (rp == null) continue;
|
|
|
|
Logger.log(cc, `found repository in '${rp}'`);
|
|
repositories.push(new Repository(folder, rp, false, anyRepoChangedFn, !window.state.focused));
|
|
}
|
|
|
|
return repositories;
|
|
}
|
|
|
|
@debug({
|
|
args: {
|
|
0: (root: string) => root,
|
|
1: (depth: number) => `${depth}`,
|
|
2: () => false,
|
|
3: () => false,
|
|
},
|
|
})
|
|
private repositorySearchCore(
|
|
root: string,
|
|
depth: number,
|
|
excludes: Record<string, boolean>,
|
|
repositories: string[] = [],
|
|
): Promise<string[]> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
return new Promise<string[]>((resolve, reject) => {
|
|
fs.readdir(root, { withFileTypes: true }, async (err, files) => {
|
|
if (err != null) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
resolve(repositories);
|
|
return;
|
|
}
|
|
|
|
depth--;
|
|
|
|
let f;
|
|
for (f of files) {
|
|
if (!f.isDirectory()) continue;
|
|
|
|
if (f.name === '.git') {
|
|
repositories.push(paths.resolve(root, f.name));
|
|
} else if (depth >= 0 && excludes[f.name] !== true) {
|
|
try {
|
|
await this.repositorySearchCore(paths.resolve(root, f.name), depth, excludes, repositories);
|
|
} catch (ex) {
|
|
Logger.error(ex, cc, 'FAILED');
|
|
}
|
|
}
|
|
}
|
|
|
|
resolve(repositories);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async updateContext(repositoryTree: TernarySearchTree<string, Repository>) {
|
|
const hasRepository = repositoryTree.any();
|
|
await setEnabled(hasRepository);
|
|
|
|
let hasRemotes = false;
|
|
let hasRichRemotes = false;
|
|
let hasConnectedRemotes = false;
|
|
if (hasRepository) {
|
|
for (const repo of repositoryTree.values()) {
|
|
if (!hasConnectedRemotes) {
|
|
hasConnectedRemotes = await repo.hasConnectedRemote();
|
|
|
|
if (hasConnectedRemotes) {
|
|
hasRichRemotes = true;
|
|
hasRemotes = true;
|
|
}
|
|
}
|
|
|
|
if (!hasRichRemotes) {
|
|
hasRichRemotes = await repo.hasRichRemote();
|
|
}
|
|
|
|
if (!hasRemotes) {
|
|
hasRemotes = await repo.hasRemotes();
|
|
}
|
|
|
|
if (hasRemotes && hasRichRemotes && hasConnectedRemotes) break;
|
|
}
|
|
}
|
|
|
|
await Promise.all([
|
|
setContext(ContextKeys.HasRemotes, hasRemotes),
|
|
setContext(ContextKeys.HasRichRemotes, hasRichRemotes),
|
|
setContext(ContextKeys.HasConnectedRemotes, hasConnectedRemotes),
|
|
]);
|
|
|
|
// If we have no repositories setup a watcher in case one is initialized
|
|
if (!hasRepository) {
|
|
const watcher = workspace.createFileSystemWatcher('**/.git', false, true, true);
|
|
const disposable = Disposable.from(
|
|
watcher,
|
|
watcher.onDidCreate(async uri => {
|
|
const f = workspace.getWorkspaceFolder(uri);
|
|
if (f == null) return;
|
|
|
|
// Search for and add all repositories (nested and/or submodules)
|
|
const repositories = await this.repositorySearch(f);
|
|
if (repositories.length === 0) return;
|
|
|
|
disposable.dispose();
|
|
|
|
for (const r of repositories) {
|
|
if (!this._repositoryTree.has(r.path)) {
|
|
this._repositoryTree.set(r.path, r);
|
|
}
|
|
}
|
|
|
|
await this.updateContext(this._repositoryTree);
|
|
|
|
// Defer the event trigger enough to let everything unwind
|
|
setImmediate(() => this.fireRepositoriesChanged());
|
|
}, this),
|
|
);
|
|
}
|
|
}
|
|
|
|
private fireRepositoriesChanged() {
|
|
this._onDidChangeRepositories.fire();
|
|
}
|
|
|
|
@log()
|
|
addRemote(repoPath: string, name: string, url: string) {
|
|
return Git.remote__add(repoPath, name, url);
|
|
}
|
|
|
|
@log()
|
|
pruneRemote(repoPath: string, remoteName: string) {
|
|
return Git.remote__prune(repoPath, remoteName);
|
|
}
|
|
|
|
@log()
|
|
async applyChangesToWorkingFile(uri: GitUri, ref1?: string, ref2?: string) {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
ref1 = ref1 ?? uri.sha;
|
|
if (ref1 == null || uri.repoPath == null) return;
|
|
|
|
if (ref2 == null) {
|
|
ref2 = ref1;
|
|
ref1 = `${ref1}^`;
|
|
}
|
|
|
|
let patch;
|
|
try {
|
|
patch = await Git.diff(uri.repoPath, uri.fsPath, ref1, ref2);
|
|
void (await Git.apply(uri.repoPath, patch));
|
|
} catch (ex) {
|
|
const msg: string = ex?.toString() ?? emptyStr;
|
|
if (patch && /patch does not apply/i.test(msg)) {
|
|
const result = await window.showWarningMessage(
|
|
'Unable to apply changes cleanly. Retry and allow conflicts?',
|
|
{ title: 'Yes' },
|
|
{ title: 'No', isCloseAffordance: true },
|
|
);
|
|
|
|
if (result == null || result.title !== 'Yes') return;
|
|
|
|
if (result.title === 'Yes') {
|
|
try {
|
|
void (await Git.apply(uri.repoPath, patch, { allowConflicts: true }));
|
|
|
|
return;
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-ex-assign
|
|
ex = e;
|
|
}
|
|
}
|
|
}
|
|
|
|
Logger.error(ex, cc);
|
|
void Messages.showGenericErrorMessage('Unable to apply changes');
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async branchContainsCommit(repoPath: string, name: string, ref: string): Promise<boolean> {
|
|
let data = await Git.branch__contains(repoPath, ref, { name: name });
|
|
data = data?.trim();
|
|
return Boolean(data);
|
|
}
|
|
|
|
@log()
|
|
async checkout(repoPath: string, ref: string, options: { createBranch?: string } | { fileName?: string } = {}) {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
try {
|
|
return await Git.checkout(repoPath, ref, options);
|
|
} catch (ex) {
|
|
const msg: string = ex?.toString() ?? emptyStr;
|
|
if (/overwritten by checkout/i.test(msg)) {
|
|
void Messages.showGenericErrorMessage(
|
|
`Unable to checkout '${ref}'. Please commit or stash your changes before switching branches`,
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
Logger.error(ex, cc);
|
|
void void Messages.showGenericErrorMessage(`Unable to checkout '${ref}'`);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async excludeIgnoredUris(repoPath: string, uris: Uri[]): Promise<Uri[]> {
|
|
const paths = new Map<string, Uri>(uris.map(u => [Strings.normalizePath(u.fsPath), u]));
|
|
|
|
const data = await Git.check_ignore(repoPath, ...paths.keys());
|
|
if (data == null) return uris;
|
|
|
|
const ignored = data.split('\0').filter(<T>(i?: T): i is T => Boolean(i));
|
|
if (ignored.length === 0) return uris;
|
|
|
|
for (const file of ignored) {
|
|
paths.delete(file);
|
|
}
|
|
|
|
return [...paths.values()];
|
|
}
|
|
|
|
@gate()
|
|
@log()
|
|
async fetch(
|
|
repoPath: string,
|
|
options: { all?: boolean; branch?: GitBranchReference; prune?: boolean; remote?: string } = {},
|
|
): Promise<void> {
|
|
const { branch: branchRef, ...opts } = options;
|
|
if (GitReference.isBranch(branchRef)) {
|
|
const repo = await this.getRepository(repoPath);
|
|
const branch = await repo?.getBranch(branchRef?.name);
|
|
if (branch?.tracking == null) return undefined;
|
|
|
|
return Git.fetch(repoPath, {
|
|
branch: branch.name,
|
|
remote: branch.getRemoteName()!,
|
|
upstream: branch.getTrackingWithoutRemote()!,
|
|
});
|
|
}
|
|
|
|
return Git.fetch(repoPath, opts);
|
|
}
|
|
|
|
@gate<GitService['fetchAll']>(
|
|
(repos, opts) => `${repos == null ? '' : repos.map(r => r.id).join(',')}|${JSON.stringify(opts)}`,
|
|
)
|
|
@log({
|
|
args: {
|
|
0: (repos?: Repository[]) => (repos == null ? false : repos.map(r => r.name).join(', ')),
|
|
},
|
|
})
|
|
async fetchAll(repositories?: Repository[], options: { all?: boolean; prune?: boolean } = {}) {
|
|
if (repositories == null) {
|
|
repositories = await this.getOrderedRepositories();
|
|
}
|
|
if (repositories.length === 0) return;
|
|
|
|
if (repositories.length === 1) {
|
|
await repositories[0].fetch(options);
|
|
|
|
return;
|
|
}
|
|
|
|
await window.withProgress(
|
|
{
|
|
location: ProgressLocation.Notification,
|
|
title: `Fetching ${repositories.length} repositories`,
|
|
},
|
|
() => Promise.all(repositories!.map(r => r.fetch({ progress: false, ...options }))),
|
|
);
|
|
}
|
|
|
|
@gate<GitService['pullAll']>(
|
|
(repos, opts) => `${repos == null ? '' : repos.map(r => r.id).join(',')}|${JSON.stringify(opts)}`,
|
|
)
|
|
@log({
|
|
args: {
|
|
0: (repos?: Repository[]) => (repos == null ? false : repos.map(r => r.name).join(', ')),
|
|
},
|
|
})
|
|
async pullAll(repositories?: Repository[], options: { rebase?: boolean } = {}) {
|
|
if (repositories == null) {
|
|
repositories = await this.getOrderedRepositories();
|
|
}
|
|
if (repositories.length === 0) return;
|
|
|
|
if (repositories.length === 1) {
|
|
await repositories[0].pull(options);
|
|
|
|
return;
|
|
}
|
|
|
|
await window.withProgress(
|
|
{
|
|
location: ProgressLocation.Notification,
|
|
title: `Pulling ${repositories.length} repositories`,
|
|
},
|
|
() => Promise.all(repositories!.map(r => r.pull({ progress: false, ...options }))),
|
|
);
|
|
}
|
|
|
|
@gate<GitService['pushAll']>(repos => `${repos == null ? '' : repos.map(r => r.id).join(',')}`)
|
|
@log({
|
|
args: {
|
|
0: (repos?: Repository[]) => (repos == null ? false : repos.map(r => r.name).join(', ')),
|
|
},
|
|
})
|
|
async pushAll(
|
|
repositories?: Repository[],
|
|
options: {
|
|
force?: boolean;
|
|
reference?: GitReference;
|
|
publish?: {
|
|
remote: string;
|
|
};
|
|
} = {},
|
|
) {
|
|
if (repositories == null) {
|
|
repositories = await this.getOrderedRepositories();
|
|
}
|
|
if (repositories.length === 0) return;
|
|
|
|
if (repositories.length === 1) {
|
|
await repositories[0].push(options);
|
|
|
|
return;
|
|
}
|
|
|
|
await window.withProgress(
|
|
{
|
|
location: ProgressLocation.Notification,
|
|
title: `Pushing ${repositories.length} repositories`,
|
|
},
|
|
() => Promise.all(repositories!.map(r => r.push({ progress: false, ...options }))),
|
|
);
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
0: (editor: TextEditor) =>
|
|
editor != null ? `TextEditor(${Logger.toLoggable(editor.document.uri)})` : 'undefined',
|
|
},
|
|
})
|
|
async getActiveRepository(editor?: TextEditor): Promise<Repository | undefined> {
|
|
const repoPath = await this.getActiveRepoPath(editor);
|
|
if (repoPath == null) return undefined;
|
|
|
|
return this.getRepository(repoPath);
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
0: (editor: TextEditor) =>
|
|
editor != null ? `TextEditor(${Logger.toLoggable(editor.document.uri)})` : 'undefined',
|
|
},
|
|
})
|
|
async getActiveRepoPath(editor?: TextEditor): Promise<string | undefined> {
|
|
editor = editor ?? window.activeTextEditor;
|
|
|
|
let repoPath;
|
|
if (editor != null) {
|
|
const doc = await Container.tracker.getOrAdd(editor.document.uri);
|
|
if (doc != null) {
|
|
repoPath = doc.uri.repoPath;
|
|
}
|
|
}
|
|
|
|
if (repoPath != null) return repoPath;
|
|
|
|
return this.getHighlanderRepoPath();
|
|
}
|
|
|
|
@log()
|
|
getHighlanderRepoPath(): string | undefined {
|
|
const entry = this._repositoryTree.highlander();
|
|
if (entry == null) return undefined;
|
|
|
|
const [, repo] = entry;
|
|
return repo.path;
|
|
}
|
|
|
|
@log()
|
|
async getBlameForFile(uri: GitUri): Promise<GitBlame | undefined> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
let key = 'blame';
|
|
if (uri.sha != null) {
|
|
key += `:${uri.sha}`;
|
|
}
|
|
|
|
const doc = await Container.tracker.getOrAdd(uri);
|
|
if (this.useCaching) {
|
|
if (doc.state != null) {
|
|
const cachedBlame = doc.state.get<CachedBlame>(key);
|
|
if (cachedBlame != null) {
|
|
Logger.debug(cc, `Cache hit: '${key}'`);
|
|
return cachedBlame.item;
|
|
}
|
|
}
|
|
|
|
Logger.debug(cc, `Cache miss: '${key}'`);
|
|
|
|
if (doc.state == null) {
|
|
doc.state = new GitDocumentState(doc.key);
|
|
}
|
|
}
|
|
|
|
const promise = this.getBlameForFileCore(uri, doc, key, cc);
|
|
|
|
if (doc.state != null) {
|
|
Logger.debug(cc, `Cache add: '${key}'`);
|
|
|
|
const value: CachedBlame = {
|
|
item: promise as Promise<GitBlame>,
|
|
};
|
|
doc.state.set<CachedBlame>(key, value);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
private async getBlameForFileCore(
|
|
uri: GitUri,
|
|
document: TrackedDocument<GitDocumentState>,
|
|
key: string,
|
|
cc: LogCorrelationContext | undefined,
|
|
): Promise<GitBlame | undefined> {
|
|
if (!(await this.isTracked(uri))) {
|
|
Logger.log(cc, `Skipping blame; '${uri.fsPath}' is not tracked`);
|
|
return emptyPromise as Promise<GitBlame>;
|
|
}
|
|
|
|
const [file, root] = Git.splitPath(uri.fsPath, uri.repoPath, false);
|
|
|
|
try {
|
|
const data = await Git.blame(root, file, uri.sha, {
|
|
args: Container.config.advanced.blame.customArguments,
|
|
ignoreWhitespace: Container.config.blame.ignoreWhitespace,
|
|
});
|
|
const blame = GitBlameParser.parse(data, root, file, await this.getCurrentUser(root));
|
|
return blame;
|
|
} catch (ex) {
|
|
// Trap and cache expected blame errors
|
|
if (document.state != null) {
|
|
const msg = ex?.toString() ?? '';
|
|
Logger.debug(cc, `Cache replace (with empty promise): '${key}'`);
|
|
|
|
const value: CachedBlame = {
|
|
item: emptyPromise as Promise<GitBlame>,
|
|
errorMessage: msg,
|
|
};
|
|
document.state.set<CachedBlame>(key, value);
|
|
|
|
document.setBlameFailure();
|
|
|
|
return emptyPromise as Promise<GitBlame>;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
1: _contents => '<contents>',
|
|
},
|
|
})
|
|
async getBlameForFileContents(uri: GitUri, contents: string): Promise<GitBlame | undefined> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
const key = `blame:${Strings.sha1(contents)}`;
|
|
|
|
const doc = await Container.tracker.getOrAdd(uri);
|
|
if (this.useCaching) {
|
|
if (doc.state != null) {
|
|
const cachedBlame = doc.state.get<CachedBlame>(key);
|
|
if (cachedBlame != null) {
|
|
Logger.debug(cc, `Cache hit: ${key}`);
|
|
return cachedBlame.item;
|
|
}
|
|
}
|
|
|
|
Logger.debug(cc, `Cache miss: ${key}`);
|
|
|
|
if (doc.state == null) {
|
|
doc.state = new GitDocumentState(doc.key);
|
|
}
|
|
}
|
|
|
|
const promise = this.getBlameForFileContentsCore(uri, contents, doc, key, cc);
|
|
|
|
if (doc.state != null) {
|
|
Logger.debug(cc, `Cache add: '${key}'`);
|
|
|
|
const value: CachedBlame = {
|
|
item: promise as Promise<GitBlame>,
|
|
};
|
|
doc.state.set<CachedBlame>(key, value);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
async getBlameForFileContentsCore(
|
|
uri: GitUri,
|
|
contents: string,
|
|
document: TrackedDocument<GitDocumentState>,
|
|
key: string,
|
|
cc: LogCorrelationContext | undefined,
|
|
): Promise<GitBlame | undefined> {
|
|
if (!(await this.isTracked(uri))) {
|
|
Logger.log(cc, `Skipping blame; '${uri.fsPath}' is not tracked`);
|
|
return emptyPromise as Promise<GitBlame>;
|
|
}
|
|
|
|
const [file, root] = Git.splitPath(uri.fsPath, uri.repoPath, false);
|
|
|
|
try {
|
|
const data = await Git.blame__contents(root, file, contents, {
|
|
args: Container.config.advanced.blame.customArguments,
|
|
correlationKey: `:${key}`,
|
|
ignoreWhitespace: Container.config.blame.ignoreWhitespace,
|
|
});
|
|
const blame = GitBlameParser.parse(data, root, file, await this.getCurrentUser(root));
|
|
return blame;
|
|
} catch (ex) {
|
|
// Trap and cache expected blame errors
|
|
if (document.state != null) {
|
|
const msg = ex?.toString() ?? '';
|
|
Logger.debug(cc, `Cache replace (with empty promise): '${key}'`);
|
|
|
|
const value: CachedBlame = {
|
|
item: emptyPromise as Promise<GitBlame>,
|
|
errorMessage: msg,
|
|
};
|
|
document.state.set<CachedBlame>(key, value);
|
|
|
|
document.setBlameFailure();
|
|
return emptyPromise as Promise<GitBlame>;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getBlameForLine(
|
|
uri: GitUri,
|
|
editorLine: number, // editor lines are 0-based
|
|
options: { skipCache?: boolean } = {},
|
|
): Promise<GitBlameLine | undefined> {
|
|
if (!options.skipCache && this.useCaching) {
|
|
const blame = await this.getBlameForFile(uri);
|
|
if (blame == null) return undefined;
|
|
|
|
let blameLine = blame.lines[editorLine];
|
|
if (blameLine == null) {
|
|
if (blame.lines.length !== editorLine) return undefined;
|
|
blameLine = blame.lines[editorLine - 1];
|
|
}
|
|
|
|
const commit = blame.commits.get(blameLine.sha);
|
|
if (commit == null) return undefined;
|
|
|
|
const author = blame.authors.get(commit.author)!;
|
|
return {
|
|
author: { ...author, lineCount: commit.lines.length },
|
|
commit: commit,
|
|
line: blameLine,
|
|
};
|
|
}
|
|
|
|
const lineToBlame = editorLine + 1;
|
|
const fileName = uri.fsPath;
|
|
|
|
try {
|
|
const data = await Git.blame(uri.repoPath, fileName, uri.sha, {
|
|
args: Container.config.advanced.blame.customArguments,
|
|
ignoreWhitespace: Container.config.blame.ignoreWhitespace,
|
|
startLine: lineToBlame,
|
|
endLine: lineToBlame,
|
|
});
|
|
const blame = GitBlameParser.parse(data, uri.repoPath, fileName, await this.getCurrentUser(uri.repoPath!));
|
|
if (blame == null) return undefined;
|
|
|
|
return {
|
|
author: Iterables.first(blame.authors.values()),
|
|
commit: Iterables.first(blame.commits.values()),
|
|
line: blame.lines[editorLine],
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
2: _contents => '<contents>',
|
|
},
|
|
})
|
|
async getBlameForLineContents(
|
|
uri: GitUri,
|
|
editorLine: number, // editor lines are 0-based
|
|
contents: string,
|
|
options: { skipCache?: boolean } = {},
|
|
): Promise<GitBlameLine | undefined> {
|
|
if (!options.skipCache && this.useCaching) {
|
|
const blame = await this.getBlameForFileContents(uri, contents);
|
|
if (blame == null) return undefined;
|
|
|
|
let blameLine = blame.lines[editorLine];
|
|
if (blameLine == null) {
|
|
if (blame.lines.length !== editorLine) return undefined;
|
|
blameLine = blame.lines[editorLine - 1];
|
|
}
|
|
|
|
const commit = blame.commits.get(blameLine.sha);
|
|
if (commit == null) return undefined;
|
|
|
|
const author = blame.authors.get(commit.author)!;
|
|
return {
|
|
author: { ...author, lineCount: commit.lines.length },
|
|
commit: commit,
|
|
line: blameLine,
|
|
};
|
|
}
|
|
|
|
const lineToBlame = editorLine + 1;
|
|
const fileName = uri.fsPath;
|
|
|
|
try {
|
|
const data = await Git.blame__contents(uri.repoPath, fileName, contents, {
|
|
args: Container.config.advanced.blame.customArguments,
|
|
ignoreWhitespace: Container.config.blame.ignoreWhitespace,
|
|
startLine: lineToBlame,
|
|
endLine: lineToBlame,
|
|
});
|
|
const currentUser = await this.getCurrentUser(uri.repoPath!);
|
|
const blame = GitBlameParser.parse(data, uri.repoPath, fileName, currentUser);
|
|
if (blame == null) return undefined;
|
|
|
|
return {
|
|
author: Iterables.first(blame.authors.values()),
|
|
commit: Iterables.first(blame.commits.values()),
|
|
line: blame.lines[editorLine],
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getBlameForRange(uri: GitUri, range: Range): Promise<GitBlameLines | undefined> {
|
|
const blame = await this.getBlameForFile(uri);
|
|
if (blame == null) return undefined;
|
|
|
|
return this.getBlameForRangeSync(blame, uri, range);
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
2: _contents => '<contents>',
|
|
},
|
|
})
|
|
async getBlameForRangeContents(uri: GitUri, range: Range, contents: string): Promise<GitBlameLines | undefined> {
|
|
const blame = await this.getBlameForFileContents(uri, contents);
|
|
if (blame == null) return undefined;
|
|
|
|
return this.getBlameForRangeSync(blame, uri, range);
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
0: _blame => '<blame>',
|
|
},
|
|
})
|
|
getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined {
|
|
if (blame.lines.length === 0) return { allLines: blame.lines, ...blame };
|
|
|
|
if (range.start.line === 0 && range.end.line === blame.lines.length - 1) {
|
|
return { allLines: blame.lines, ...blame };
|
|
}
|
|
|
|
const lines = blame.lines.slice(range.start.line, range.end.line + 1);
|
|
const shas = new Set(lines.map(l => l.sha));
|
|
|
|
// ranges are 0-based
|
|
const startLine = range.start.line + 1;
|
|
const endLine = range.end.line + 1;
|
|
|
|
const authors = new Map<string, GitAuthor>();
|
|
const commits = new Map<string, GitBlameCommit>();
|
|
for (const c of blame.commits.values()) {
|
|
if (!shas.has(c.sha)) continue;
|
|
|
|
const commit = c.with({
|
|
lines: c.lines.filter(l => l.line >= startLine && l.line <= endLine),
|
|
});
|
|
commits.set(c.sha, commit);
|
|
|
|
let author = authors.get(commit.author);
|
|
if (author == null) {
|
|
author = {
|
|
name: commit.author,
|
|
lineCount: 0,
|
|
};
|
|
authors.set(author.name, author);
|
|
}
|
|
|
|
author.lineCount += commit.lines.length;
|
|
}
|
|
|
|
const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount));
|
|
|
|
return {
|
|
repoPath: uri.repoPath!,
|
|
authors: sortedAuthors,
|
|
commits: commits,
|
|
lines: lines,
|
|
allLines: blame.lines,
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getBranch(repoPath: string | undefined): Promise<GitBranch | undefined> {
|
|
if (repoPath == null) return undefined;
|
|
|
|
let [branch] = await this.getBranches(repoPath, { filter: b => b.current });
|
|
if (branch != null) return branch;
|
|
|
|
const data = await Git.rev_parse__currentBranch(repoPath);
|
|
if (data == null) return undefined;
|
|
|
|
const [name, tracking] = data[0].split('\n');
|
|
if (GitBranch.isDetached(name)) {
|
|
const committerDate = await Git.log__recent_committerdate(repoPath);
|
|
|
|
branch = new GitBranch(
|
|
repoPath,
|
|
name,
|
|
false,
|
|
true,
|
|
committerDate == null ? undefined : new Date(Number(committerDate) * 1000),
|
|
data[1],
|
|
tracking,
|
|
);
|
|
}
|
|
|
|
return branch;
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
0: b => b.name,
|
|
},
|
|
})
|
|
async getBranchAheadRange(branch: GitBranch) {
|
|
if (branch.state.ahead > 0) {
|
|
return GitRevision.createRange(branch.tracking, branch.ref);
|
|
}
|
|
|
|
if (!branch.tracking) {
|
|
// If we have no tracking branch, try to find a best guess branch to use as the "base"
|
|
const branches = await this.getBranches(branch.repoPath, {
|
|
filter: b => weightedDefaultBranches.has(b.name),
|
|
});
|
|
if (branches.length > 0) {
|
|
let weightedBranch: { weight: number; branch: GitBranch } | undefined;
|
|
for (const branch of branches) {
|
|
const weight = weightedDefaultBranches.get(branch.name)!;
|
|
if (weightedBranch == null || weightedBranch.weight < weight) {
|
|
weightedBranch = { weight: weight, branch: branch };
|
|
}
|
|
|
|
if (weightedBranch.weight === maxDefaultBranchWeight) break;
|
|
}
|
|
|
|
return GitRevision.createRange(
|
|
weightedBranch!.branch.tracking ?? weightedBranch!.branch.ref,
|
|
branch.ref,
|
|
);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
@log()
|
|
async getBranches(
|
|
repoPath: string | undefined,
|
|
options: {
|
|
filter?: (b: GitBranch) => boolean;
|
|
sort?: boolean | { current?: boolean; orderBy?: BranchSorting };
|
|
} = {},
|
|
): Promise<GitBranch[]> {
|
|
if (repoPath == null) return [];
|
|
|
|
let branches = this.useCaching ? this._branchesCache.get(repoPath) : undefined;
|
|
if (branches == null) {
|
|
const data = await Git.for_each_ref__branch(repoPath, { all: true });
|
|
// If we don't get any data, assume the repo doesn't have any commits yet so check if we have a current branch
|
|
if (data == null || data.length === 0) {
|
|
let current;
|
|
|
|
const data = await Git.rev_parse__currentBranch(repoPath);
|
|
if (data != null) {
|
|
const committerDate = await Git.log__recent_committerdate(repoPath);
|
|
|
|
const [name, tracking] = data[0].split('\n');
|
|
current = new GitBranch(
|
|
repoPath,
|
|
name,
|
|
false,
|
|
true,
|
|
committerDate == null ? undefined : new Date(Number(committerDate) * 1000),
|
|
data[1],
|
|
tracking,
|
|
);
|
|
}
|
|
|
|
branches = current != null ? [current] : [];
|
|
} else {
|
|
branches = GitBranchParser.parse(data, repoPath);
|
|
}
|
|
|
|
if (this.useCaching) {
|
|
const repo = await this.getRepository(repoPath);
|
|
if (repo?.supportsChangeEvents) {
|
|
this._branchesCache.set(repoPath, branches);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.filter != null) {
|
|
branches = branches.filter(options.filter);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
if (options.sort) {
|
|
GitBranch.sort(branches, typeof options.sort === 'boolean' ? undefined : options.sort);
|
|
}
|
|
|
|
return branches;
|
|
}
|
|
|
|
@log()
|
|
async getBranchesAndOrTags(
|
|
repoPath: string | undefined,
|
|
{
|
|
filter,
|
|
include,
|
|
sort,
|
|
...options
|
|
}: {
|
|
filter?: { branches?: (b: GitBranch) => boolean; tags?: (t: GitTag) => boolean };
|
|
include?: 'all' | 'branches' | 'tags';
|
|
sort?:
|
|
| boolean
|
|
| { branches?: { current?: boolean; orderBy?: BranchSorting }; tags?: { orderBy?: TagSorting } };
|
|
} = {},
|
|
) {
|
|
const [branches, tags] = await Promise.all<GitBranch[] | undefined, GitTag[] | undefined>([
|
|
include == null || include === 'all' || include === 'branches'
|
|
? this.getBranches(repoPath, {
|
|
...options,
|
|
filter: filter?.branches,
|
|
sort: typeof sort === 'boolean' ? undefined : sort?.branches,
|
|
})
|
|
: undefined,
|
|
include == null || include === 'all' || include === 'tags'
|
|
? this.getTags(repoPath, {
|
|
...options,
|
|
filter: filter?.tags,
|
|
sort: typeof sort === 'boolean' ? undefined : sort?.tags,
|
|
})
|
|
: undefined,
|
|
]);
|
|
|
|
if (branches != null && tags != null) {
|
|
return [...branches.filter(b => !b.remote), ...tags, ...branches.filter(b => b.remote)];
|
|
}
|
|
|
|
return branches ?? tags;
|
|
}
|
|
|
|
@log()
|
|
async getBranchesAndTagsTipsFn(repoPath: string | undefined, currentName?: string) {
|
|
const [branches, tags] = await Promise.all([this.getBranches(repoPath), this.getTags(repoPath)]);
|
|
|
|
const branchesAndTagsBySha = Arrays.groupByFilterMap(
|
|
(branches as (GitBranch | GitTag)[]).concat(tags as (GitBranch | GitTag)[]),
|
|
bt => bt.sha,
|
|
bt => {
|
|
if (currentName) {
|
|
if (bt.name === currentName) return undefined;
|
|
if (bt.refType === 'branch' && bt.getNameWithoutRemote() === currentName) {
|
|
return { name: bt.name, compactName: bt.getRemoteName() };
|
|
}
|
|
}
|
|
|
|
return { name: bt.name };
|
|
},
|
|
);
|
|
|
|
return (sha: string, compact?: boolean): string | undefined => {
|
|
const branchesAndTags = branchesAndTagsBySha.get(sha);
|
|
if (branchesAndTags == null || branchesAndTags.length === 0) return undefined;
|
|
|
|
if (!compact) return branchesAndTags.map(bt => bt.name).join(', ');
|
|
|
|
if (branchesAndTags.length > 1) {
|
|
return [branchesAndTags[0], { name: GlyphChars.Ellipsis }]
|
|
.map(bt => bt.compactName ?? bt.name)
|
|
.join(', ');
|
|
}
|
|
|
|
return branchesAndTags.map(bt => bt.compactName ?? bt.name).join(', ');
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getChangedFilesCount(repoPath: string, ref?: string): Promise<GitDiffShortStat | undefined> {
|
|
const data = await Git.diff__shortstat(repoPath, ref);
|
|
if (!data) return undefined;
|
|
|
|
return GitDiffParser.parseShortStat(data);
|
|
}
|
|
|
|
@log()
|
|
async getCommit(repoPath: string, ref: string): Promise<GitLogCommit | undefined> {
|
|
const log = await this.getLog(repoPath, { limit: 2, ref: ref });
|
|
if (log == null) return undefined;
|
|
|
|
return log.commits.get(ref) ?? Iterables.first(log.commits.values());
|
|
}
|
|
|
|
@log()
|
|
async getCommitBranches(repoPath: string, ref: string, options?: { remotes?: boolean }): Promise<string[]> {
|
|
const data = await Git.branch__contains(repoPath, ref, options);
|
|
if (!data) return [];
|
|
|
|
return data
|
|
.split('\n')
|
|
.map(b => b.substr(2).trim())
|
|
.filter(<T>(i?: T): i is T => Boolean(i));
|
|
}
|
|
|
|
@log()
|
|
getAheadBehindCommitCount(
|
|
repoPath: string,
|
|
refs: string[],
|
|
): Promise<{ ahead: number; behind: number } | undefined> {
|
|
return Git.rev_list__left_right(repoPath, refs);
|
|
}
|
|
|
|
@log()
|
|
getCommitCount(repoPath: string, ref: string): Promise<number | undefined> {
|
|
return Git.rev_list__count(repoPath, ref);
|
|
}
|
|
|
|
@log()
|
|
async getCommitForFile(
|
|
repoPath: string | undefined,
|
|
fileName: string,
|
|
options: { ref?: string; firstIfNotFound?: boolean; range?: Range; reverse?: boolean } = {},
|
|
): Promise<GitLogCommit | undefined> {
|
|
const log = await this.getLogForFile(repoPath, fileName, {
|
|
limit: 2,
|
|
ref: options.ref,
|
|
range: options.range,
|
|
reverse: options.reverse,
|
|
});
|
|
if (log == null) return undefined;
|
|
|
|
const commit = options.ref ? log.commits.get(options.ref) : undefined;
|
|
if (commit == null && !options.firstIfNotFound && options.ref) {
|
|
// If the ref isn't a valid sha we will never find it, so let it fall through so we return the first
|
|
if (GitRevision.isSha(options.ref) || GitRevision.isUncommitted(options.ref)) return undefined;
|
|
}
|
|
|
|
return commit ?? Iterables.first(log.commits.values());
|
|
}
|
|
|
|
@log()
|
|
async getOldestUnpushedRefForFile(repoPath: string, fileName: string): Promise<string | undefined> {
|
|
const data = await Git.log__file(repoPath, fileName, '@{push}..', {
|
|
format: 'refs',
|
|
renames: true,
|
|
});
|
|
if (data == null || data.length === 0) return undefined;
|
|
|
|
return GitLogParser.parseLastRefOnly(data);
|
|
}
|
|
|
|
@log()
|
|
getConfig(key: string, repoPath?: string): Promise<string | undefined> {
|
|
return Git.config__get(key, repoPath);
|
|
}
|
|
|
|
@log()
|
|
async getContributors(repoPath: string): Promise<GitContributor[]> {
|
|
if (repoPath == null) return [];
|
|
|
|
const data = await Git.shortlog(repoPath);
|
|
const shortlog = GitShortLogParser.parse(data, repoPath);
|
|
if (shortlog == null) return [];
|
|
|
|
// Mark the current user
|
|
const currentUser = await Container.git.getCurrentUser(repoPath);
|
|
if (currentUser != null) {
|
|
const index = shortlog.contributors.findIndex(
|
|
c => currentUser.email === c.email && currentUser.name === c.name,
|
|
);
|
|
if (index !== -1) {
|
|
const c = shortlog.contributors[index];
|
|
shortlog.contributors.splice(index, 1, new GitContributor(c.repoPath, c.name, c.email, c.count, true));
|
|
}
|
|
}
|
|
|
|
return shortlog.contributors;
|
|
}
|
|
|
|
@log()
|
|
@gate()
|
|
async getCurrentUser(repoPath: string) {
|
|
let user = this._userMapCache.get(repoPath);
|
|
if (user != null) return user;
|
|
// If we found the repo, but no user data was found just return
|
|
if (user === null) return undefined;
|
|
|
|
const data = await Git.config__get_regex('^user\\.', repoPath, { local: true });
|
|
if (!data) {
|
|
// If we found no user data, mark it so we won't bother trying again
|
|
this._userMapCache.set(repoPath, null);
|
|
return undefined;
|
|
}
|
|
|
|
user = { name: undefined, email: undefined };
|
|
|
|
let key: string;
|
|
let value: string;
|
|
|
|
let match;
|
|
do {
|
|
match = userConfigRegex.exec(data);
|
|
if (match == null) break;
|
|
|
|
[, key, value] = match;
|
|
// Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
|
|
user[key as 'name' | 'email'] = ` ${value}`.substr(1);
|
|
} while (true);
|
|
|
|
const author = `${user.name} <${user.email}>`;
|
|
// Check if there is a mailmap for the current user
|
|
const mappedAuthor = await Git.check_mailmap(repoPath, author);
|
|
if (mappedAuthor != null && mappedAuthor.length !== 0 && author !== mappedAuthor) {
|
|
match = mappedAuthorRegex.exec(mappedAuthor);
|
|
if (match != null) {
|
|
[, user.name, user.email] = match;
|
|
}
|
|
}
|
|
|
|
this._userMapCache.set(repoPath, user);
|
|
return user;
|
|
}
|
|
|
|
@log()
|
|
async getDiffForFile(
|
|
uri: GitUri,
|
|
ref1: string | undefined,
|
|
ref2?: string,
|
|
originalFileName?: string,
|
|
): Promise<GitDiff | undefined> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
let key = 'diff';
|
|
if (ref1 != null) {
|
|
key += `:${ref1}`;
|
|
}
|
|
if (ref2 != null) {
|
|
key += `:${ref2}`;
|
|
}
|
|
|
|
const doc = await Container.tracker.getOrAdd(uri);
|
|
if (this.useCaching) {
|
|
if (doc.state != null) {
|
|
const cachedDiff = doc.state.get<CachedDiff>(key);
|
|
if (cachedDiff != null) {
|
|
Logger.debug(cc, `Cache hit: '${key}'`);
|
|
return cachedDiff.item;
|
|
}
|
|
}
|
|
|
|
Logger.debug(cc, `Cache miss: '${key}'`);
|
|
|
|
if (doc.state == null) {
|
|
doc.state = new GitDocumentState(doc.key);
|
|
}
|
|
}
|
|
|
|
const promise = this.getDiffForFileCore(
|
|
uri.repoPath,
|
|
uri.fsPath,
|
|
ref1,
|
|
ref2,
|
|
originalFileName,
|
|
{ encoding: GitService.getEncoding(uri) },
|
|
doc,
|
|
key,
|
|
cc,
|
|
);
|
|
|
|
if (doc.state != null) {
|
|
Logger.debug(cc, `Cache add: '${key}'`);
|
|
|
|
const value: CachedDiff = {
|
|
item: promise as Promise<GitDiff>,
|
|
};
|
|
doc.state.set<CachedDiff>(key, value);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
private async getDiffForFileCore(
|
|
repoPath: string | undefined,
|
|
fileName: string,
|
|
ref1: string | undefined,
|
|
ref2: string | undefined,
|
|
originalFileName: string | undefined,
|
|
options: { encoding?: string },
|
|
document: TrackedDocument<GitDocumentState>,
|
|
key: string,
|
|
cc: LogCorrelationContext | undefined,
|
|
): Promise<GitDiff | undefined> {
|
|
const [file, root] = Git.splitPath(fileName, repoPath, false);
|
|
|
|
try {
|
|
// let data;
|
|
// if (ref2 == null && ref1 != null && !GitRevision.isUncommittedStaged(ref1)) {
|
|
// data = await Git.show__diff(root, file, ref1, originalFileName, {
|
|
// similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
// });
|
|
// } else {
|
|
const data = await Git.diff(root, file, ref1, ref2, {
|
|
...options,
|
|
filters: ['M'],
|
|
linesOfContext: 0,
|
|
renames: true,
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
});
|
|
// }
|
|
|
|
const diff = GitDiffParser.parse(data);
|
|
return diff;
|
|
} catch (ex) {
|
|
// Trap and cache expected diff errors
|
|
if (document.state != null) {
|
|
const msg = ex?.toString() ?? '';
|
|
Logger.debug(cc, `Cache replace (with empty promise): '${key}'`);
|
|
|
|
const value: CachedDiff = {
|
|
item: emptyPromise as Promise<GitDiff>,
|
|
errorMessage: msg,
|
|
};
|
|
document.state.set<CachedDiff>(key, value);
|
|
|
|
return emptyPromise as Promise<GitDiff>;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log({
|
|
args: {
|
|
1: _contents => '<contents>',
|
|
},
|
|
})
|
|
async getDiffForFileContents(
|
|
uri: GitUri,
|
|
ref: string,
|
|
contents: string,
|
|
originalFileName?: string,
|
|
): Promise<GitDiff | undefined> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
const key = `diff:${Strings.sha1(contents)}`;
|
|
|
|
const doc = await Container.tracker.getOrAdd(uri);
|
|
if (this.useCaching) {
|
|
if (doc.state != null) {
|
|
const cachedDiff = doc.state.get<CachedDiff>(key);
|
|
if (cachedDiff != null) {
|
|
Logger.debug(cc, `Cache hit: ${key}`);
|
|
return cachedDiff.item;
|
|
}
|
|
}
|
|
|
|
Logger.debug(cc, `Cache miss: ${key}`);
|
|
|
|
if (doc.state == null) {
|
|
doc.state = new GitDocumentState(doc.key);
|
|
}
|
|
}
|
|
|
|
const promise = this.getDiffForFileContentsCore(
|
|
uri.repoPath,
|
|
uri.fsPath,
|
|
ref,
|
|
contents,
|
|
originalFileName,
|
|
{ encoding: GitService.getEncoding(uri) },
|
|
doc,
|
|
key,
|
|
cc,
|
|
);
|
|
|
|
if (doc.state != null) {
|
|
Logger.debug(cc, `Cache add: '${key}'`);
|
|
|
|
const value: CachedDiff = {
|
|
item: promise as Promise<GitDiff>,
|
|
};
|
|
doc.state.set<CachedDiff>(key, value);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
async getDiffForFileContentsCore(
|
|
repoPath: string | undefined,
|
|
fileName: string,
|
|
ref: string,
|
|
contents: string,
|
|
originalFileName: string | undefined,
|
|
options: { encoding?: string },
|
|
document: TrackedDocument<GitDocumentState>,
|
|
key: string,
|
|
cc: LogCorrelationContext | undefined,
|
|
): Promise<GitDiff | undefined> {
|
|
const [file, root] = Git.splitPath(fileName, repoPath, false);
|
|
|
|
try {
|
|
const data = await Git.diff__contents(root, file, ref, contents, {
|
|
...options,
|
|
filters: ['M'],
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
});
|
|
|
|
const diff = GitDiffParser.parse(data);
|
|
return diff;
|
|
} catch (ex) {
|
|
// Trap and cache expected diff errors
|
|
if (document.state != null) {
|
|
const msg = ex?.toString() ?? '';
|
|
Logger.debug(cc, `Cache replace (with empty promise): '${key}'`);
|
|
|
|
const value: CachedDiff = {
|
|
item: emptyPromise as Promise<GitDiff>,
|
|
errorMessage: msg,
|
|
};
|
|
document.state.set<CachedDiff>(key, value);
|
|
|
|
return emptyPromise as Promise<GitDiff>;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getDiffForLine(
|
|
uri: GitUri,
|
|
editorLine: number, // editor lines are 0-based
|
|
ref1: string | undefined,
|
|
ref2?: string,
|
|
originalFileName?: string,
|
|
): Promise<GitDiffHunkLine | undefined> {
|
|
try {
|
|
const diff = await this.getDiffForFile(uri, ref1, ref2, originalFileName);
|
|
if (diff == null) return undefined;
|
|
|
|
const line = editorLine + 1;
|
|
const hunk = diff.hunks.find(c => c.current.position.start <= line && c.current.position.end >= line);
|
|
if (hunk == null) return undefined;
|
|
|
|
return hunk.lines[line - hunk.current.position.start];
|
|
} catch (ex) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getDiffStatus(
|
|
repoPath: string,
|
|
ref1?: string,
|
|
ref2?: string,
|
|
options: { filters?: GitDiffFilter[]; similarityThreshold?: number } = {},
|
|
): Promise<GitFile[] | undefined> {
|
|
try {
|
|
const data = await Git.diff__name_status(repoPath, ref1, ref2, {
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
...options,
|
|
});
|
|
const files = GitDiffParser.parseNameStatus(data, repoPath);
|
|
return files == null || files.length === 0 ? undefined : files;
|
|
} catch (ex) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getFileStatusForCommit(repoPath: string, fileName: string, ref: string): Promise<GitFile | undefined> {
|
|
if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return undefined;
|
|
|
|
const data = await Git.show__name_status(repoPath, fileName, ref);
|
|
if (!data) return undefined;
|
|
|
|
const files = GitDiffParser.parseNameStatus(data, repoPath);
|
|
if (files == null || files.length === 0) return undefined;
|
|
|
|
return files[0];
|
|
}
|
|
|
|
@log()
|
|
async getLog(
|
|
repoPath: string,
|
|
{
|
|
ref,
|
|
...options
|
|
}: {
|
|
authors?: string[];
|
|
limit?: number;
|
|
merges?: boolean;
|
|
ref?: string;
|
|
reverse?: boolean;
|
|
since?: string;
|
|
} = {},
|
|
): Promise<GitLog | undefined> {
|
|
const limit = options.limit ?? Container.config.advanced.maxListItems ?? 0;
|
|
|
|
try {
|
|
const data = await Git.log(repoPath, ref, {
|
|
authors: options.authors,
|
|
limit: limit,
|
|
merges: options.merges == null ? true : options.merges,
|
|
reverse: options.reverse,
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
since: options.since,
|
|
});
|
|
const log = GitLogParser.parse(
|
|
data,
|
|
GitCommitType.Log,
|
|
repoPath,
|
|
undefined,
|
|
ref,
|
|
await this.getCurrentUser(repoPath),
|
|
limit,
|
|
options.reverse!,
|
|
undefined,
|
|
);
|
|
|
|
if (log != null) {
|
|
const opts = { ...options, ref: ref };
|
|
log.query = (limit: number | undefined) => this.getLog(repoPath, { ...opts, limit: limit });
|
|
if (log.hasMore) {
|
|
log.more = this.getLogMoreFn(log, opts);
|
|
}
|
|
}
|
|
|
|
return log;
|
|
} catch (ex) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getLogRefsOnly(
|
|
repoPath: string,
|
|
{
|
|
ref,
|
|
...options
|
|
}: {
|
|
authors?: string[];
|
|
limit?: number;
|
|
merges?: boolean;
|
|
ref?: string;
|
|
reverse?: boolean;
|
|
since?: string;
|
|
} = {},
|
|
): Promise<Set<string> | undefined> {
|
|
const limit = options.limit ?? Container.config.advanced.maxListItems ?? 0;
|
|
|
|
try {
|
|
const data = await Git.log(repoPath, ref, {
|
|
authors: options.authors,
|
|
format: 'refs',
|
|
limit: limit,
|
|
merges: options.merges == null ? true : options.merges,
|
|
reverse: options.reverse,
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
since: options.since,
|
|
});
|
|
const commits = GitLogParser.parseRefsOnly(data);
|
|
return new Set(commits);
|
|
} catch (ex) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private getLogMoreFn(
|
|
log: GitLog,
|
|
options: { authors?: string[]; limit?: number; merges?: boolean; ref?: string; reverse?: boolean },
|
|
): (limit: number | { until: string } | undefined) => Promise<GitLog> {
|
|
return async (limit: number | { until: string } | undefined) => {
|
|
const moreUntil = limit != null && typeof limit === 'object' ? limit.until : undefined;
|
|
let moreLimit = typeof limit === 'number' ? limit : undefined;
|
|
|
|
if (moreUntil && Iterables.some(log.commits.values(), c => c.ref === moreUntil)) {
|
|
return log;
|
|
}
|
|
|
|
moreLimit = moreLimit ?? Container.config.advanced.maxSearchItems ?? 0;
|
|
|
|
// If the log is for a range, then just get everything prior + more
|
|
if (GitRevision.isRange(log.sha)) {
|
|
const moreLog = await this.getLog(log.repoPath, {
|
|
...options,
|
|
limit: moreLimit === 0 ? 0 : (options.limit ?? 0) + moreLimit,
|
|
});
|
|
// If we can't find any more, assume we have everything
|
|
if (moreLog == null) return { ...log, hasMore: false };
|
|
|
|
return moreLog;
|
|
}
|
|
|
|
const ref = Iterables.last(log.commits.values())?.ref;
|
|
const moreLog = await this.getLog(log.repoPath, {
|
|
...options,
|
|
limit: moreUntil == null ? moreLimit : 0,
|
|
ref: moreUntil == null ? `${ref}^` : `${moreUntil}^..${ref}^`,
|
|
});
|
|
// If we can't find any more, assume we have everything
|
|
if (moreLog == null) return { ...log, hasMore: false };
|
|
|
|
// Merge authors
|
|
const authors = new Map([...log.authors]);
|
|
for (const [key, addAuthor] of moreLog.authors) {
|
|
const author = authors.get(key);
|
|
if (author == null) {
|
|
authors.set(key, addAuthor);
|
|
} else {
|
|
author.lineCount += addAuthor.lineCount;
|
|
}
|
|
}
|
|
|
|
const commits = new Map([...log.commits, ...moreLog.commits]);
|
|
|
|
const mergedLog: GitLog = {
|
|
repoPath: log.repoPath,
|
|
authors: authors,
|
|
commits: commits,
|
|
sha: log.sha,
|
|
range: undefined,
|
|
count: commits.size,
|
|
limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined,
|
|
hasMore: moreUntil == null ? moreLog.hasMore : true,
|
|
query: (limit: number | undefined) => this.getLog(log.repoPath, { ...options, limit: limit }),
|
|
};
|
|
mergedLog.more = this.getLogMoreFn(mergedLog, options);
|
|
|
|
return mergedLog;
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getLogForSearch(
|
|
repoPath: string,
|
|
search: SearchPattern,
|
|
options: { limit?: number; skip?: number } = {},
|
|
): Promise<GitLog | undefined> {
|
|
search = { matchAll: false, matchCase: false, matchRegex: true, ...search };
|
|
|
|
try {
|
|
const limit = options.limit ?? Container.config.advanced.maxSearchItems ?? 0;
|
|
const similarityThreshold = Container.config.advanced.similarityThreshold;
|
|
|
|
const operations = SearchPattern.parseSearchOperations(search.pattern);
|
|
|
|
const searchArgs = new Set<string>();
|
|
const files: string[] = [];
|
|
|
|
let useShow = false;
|
|
|
|
let op;
|
|
let values = operations.get('commit:');
|
|
if (values != null) {
|
|
useShow = true;
|
|
|
|
searchArgs.add('-m');
|
|
searchArgs.add(`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`);
|
|
for (const value of values) {
|
|
searchArgs.add(value.replace(doubleQuoteRegex, ''));
|
|
}
|
|
} else {
|
|
searchArgs.add(`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`);
|
|
searchArgs.add('--all');
|
|
searchArgs.add('--full-history');
|
|
searchArgs.add(search.matchRegex ? '--extended-regexp' : '--fixed-strings');
|
|
if (search.matchRegex && !search.matchCase) {
|
|
searchArgs.add('--regexp-ignore-case');
|
|
}
|
|
|
|
for ([op, values] of operations.entries()) {
|
|
switch (op) {
|
|
case 'message:':
|
|
searchArgs.add('-m');
|
|
if (search.matchAll) {
|
|
searchArgs.add('--all-match');
|
|
}
|
|
for (const value of values) {
|
|
searchArgs.add(`--grep=${value.replace(doubleQuoteRegex, '\\b')}`);
|
|
}
|
|
|
|
break;
|
|
|
|
case 'author:':
|
|
searchArgs.add('-m');
|
|
for (const value of values) {
|
|
searchArgs.add(`--author=${value.replace(doubleQuoteRegex, '\\b')}`);
|
|
}
|
|
|
|
break;
|
|
|
|
case 'change:':
|
|
for (const value of values) {
|
|
searchArgs.add(`-G${value}`);
|
|
}
|
|
|
|
break;
|
|
|
|
case 'file:':
|
|
for (const value of values) {
|
|
files.push(value.replace(doubleQuoteRegex, ''));
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const args = [...searchArgs.values(), '--'];
|
|
if (files.length !== 0) {
|
|
args.push(...files);
|
|
}
|
|
|
|
const data = await Git.log__search(repoPath, args, { ...options, limit: limit, useShow: useShow });
|
|
const log = GitLogParser.parse(
|
|
data,
|
|
GitCommitType.Log,
|
|
repoPath,
|
|
undefined,
|
|
undefined,
|
|
await this.getCurrentUser(repoPath),
|
|
limit,
|
|
false,
|
|
undefined,
|
|
);
|
|
|
|
if (log != null) {
|
|
log.query = (limit: number | undefined) =>
|
|
this.getLogForSearch(repoPath, search, { ...options, limit: limit });
|
|
if (log.hasMore) {
|
|
log.more = this.getLogForSearchMoreFn(log, search, options);
|
|
}
|
|
}
|
|
|
|
return log;
|
|
} catch (ex) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private getLogForSearchMoreFn(
|
|
log: GitLog,
|
|
search: SearchPattern,
|
|
options: { limit?: number },
|
|
): (limit: number | undefined) => Promise<GitLog> {
|
|
return async (limit: number | undefined) => {
|
|
limit = limit ?? Container.config.advanced.maxSearchItems ?? 0;
|
|
|
|
const moreLog = await this.getLogForSearch(log.repoPath, search, {
|
|
...options,
|
|
limit: limit,
|
|
skip: log.count,
|
|
});
|
|
if (moreLog == null) {
|
|
// If we can't find any more, assume we have everything
|
|
return { ...log, hasMore: false };
|
|
}
|
|
|
|
// Merge authors
|
|
const authors = new Map([...log.authors]);
|
|
for (const [key, addAuthor] of moreLog.authors) {
|
|
const author = authors.get(key);
|
|
if (author == null) {
|
|
authors.set(key, addAuthor);
|
|
} else {
|
|
author.lineCount += addAuthor.lineCount;
|
|
}
|
|
}
|
|
|
|
const commits = new Map([...log.commits, ...moreLog.commits]);
|
|
|
|
const mergedLog: GitLog = {
|
|
repoPath: log.repoPath,
|
|
authors: authors,
|
|
commits: commits,
|
|
sha: log.sha,
|
|
range: log.range,
|
|
count: commits.size,
|
|
limit: (log.limit ?? 0) + limit,
|
|
hasMore: moreLog.hasMore,
|
|
query: (limit: number | undefined) =>
|
|
this.getLogForSearch(log.repoPath, search, { ...options, limit: limit }),
|
|
};
|
|
mergedLog.more = this.getLogForSearchMoreFn(mergedLog, search, options);
|
|
|
|
return mergedLog;
|
|
};
|
|
}
|
|
@log()
|
|
async getLogForFile(
|
|
repoPath: string | undefined,
|
|
fileName: string,
|
|
options: {
|
|
all?: boolean;
|
|
limit?: number;
|
|
range?: Range;
|
|
ref?: string;
|
|
renames?: boolean;
|
|
reverse?: boolean;
|
|
since?: string;
|
|
skip?: number;
|
|
} = {},
|
|
): Promise<GitLog | undefined> {
|
|
if (repoPath != null && repoPath === Strings.normalizePath(fileName)) {
|
|
throw new Error(`File name cannot match the repository path; fileName=${fileName}`);
|
|
}
|
|
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
options = { reverse: false, ...options };
|
|
|
|
if (options.renames == null) {
|
|
options.renames = Container.config.advanced.fileHistoryFollowsRenames;
|
|
}
|
|
|
|
let key = 'log';
|
|
if (options.ref != null) {
|
|
key += `:${options.ref}`;
|
|
}
|
|
|
|
if (options.all == null) {
|
|
options.all = Container.config.advanced.fileHistoryShowAllBranches;
|
|
}
|
|
if (options.all) {
|
|
key += ':all';
|
|
}
|
|
|
|
options.limit = options.limit == null ? Container.config.advanced.maxListItems || 0 : options.limit;
|
|
if (options.limit) {
|
|
key += `:n${options.limit}`;
|
|
}
|
|
|
|
if (options.renames) {
|
|
key += ':follow';
|
|
}
|
|
|
|
if (options.reverse) {
|
|
key += ':reverse';
|
|
}
|
|
|
|
if (options.since) {
|
|
key += `:since=${options.since}`;
|
|
}
|
|
|
|
if (options.skip) {
|
|
key += `:skip${options.skip}`;
|
|
}
|
|
|
|
const doc = await Container.tracker.getOrAdd(GitUri.fromFile(fileName, repoPath!, options.ref));
|
|
if (this.useCaching && options.range == null) {
|
|
if (doc.state != null) {
|
|
const cachedLog = doc.state.get<CachedLog>(key);
|
|
if (cachedLog != null) {
|
|
Logger.debug(cc, `Cache hit: '${key}'`);
|
|
return cachedLog.item;
|
|
}
|
|
|
|
if (options.ref != null || options.limit != null) {
|
|
// Since we are looking for partial log, see if we have the log of the whole file
|
|
const cachedLog = doc.state.get<CachedLog>(
|
|
`log${options.renames ? ':follow' : emptyStr}${options.reverse ? ':reverse' : emptyStr}`,
|
|
);
|
|
if (cachedLog != null) {
|
|
if (options.ref == null) {
|
|
Logger.debug(cc, `Cache hit: ~'${key}'`);
|
|
return cachedLog.item;
|
|
}
|
|
|
|
Logger.debug(cc, `Cache ?: '${key}'`);
|
|
let log = await cachedLog.item;
|
|
if (log != null && !log.hasMore && log.commits.has(options.ref)) {
|
|
Logger.debug(cc, `Cache hit: '${key}'`);
|
|
|
|
// Create a copy of the log starting at the requested commit
|
|
let skip = true;
|
|
let i = 0;
|
|
const authors = new Map<string, GitAuthor>();
|
|
const commits = new Map(
|
|
Iterables.filterMap<[string, GitLogCommit], [string, GitLogCommit]>(
|
|
log.commits.entries(),
|
|
([ref, c]) => {
|
|
if (skip) {
|
|
if (ref !== options.ref) return undefined;
|
|
skip = false;
|
|
}
|
|
|
|
i++;
|
|
if (options.limit != null && i > options.limit) {
|
|
return undefined;
|
|
}
|
|
|
|
authors.set(c.author, log.authors.get(c.author)!);
|
|
return [ref, c];
|
|
},
|
|
),
|
|
);
|
|
|
|
const opts = { ...options };
|
|
log = {
|
|
...log,
|
|
limit: options.limit,
|
|
count: commits.size,
|
|
commits: commits,
|
|
authors: authors,
|
|
query: (limit: number | undefined) =>
|
|
this.getLogForFile(repoPath, fileName, { ...opts, limit: limit }),
|
|
};
|
|
|
|
return log;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Logger.debug(cc, `Cache miss: '${key}'`);
|
|
|
|
if (doc.state == null) {
|
|
doc.state = new GitDocumentState(doc.key);
|
|
}
|
|
}
|
|
|
|
const promise = this.getLogForFileCore(repoPath, fileName, options, doc, key, cc);
|
|
|
|
if (doc.state != null && options.range == null) {
|
|
Logger.debug(cc, `Cache add: '${key}'`);
|
|
|
|
const value: CachedLog = {
|
|
item: promise as Promise<GitLog>,
|
|
};
|
|
doc.state.set<CachedLog>(key, value);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
private async getLogForFileCore(
|
|
repoPath: string | undefined,
|
|
fileName: string,
|
|
{
|
|
ref,
|
|
range,
|
|
...options
|
|
}: {
|
|
all?: boolean;
|
|
limit?: number;
|
|
range?: Range;
|
|
ref?: string;
|
|
renames?: boolean;
|
|
reverse?: boolean;
|
|
since?: string;
|
|
skip?: number;
|
|
},
|
|
document: TrackedDocument<GitDocumentState>,
|
|
key: string,
|
|
cc: LogCorrelationContext | undefined,
|
|
): Promise<GitLog | undefined> {
|
|
if (!(await this.isTracked(fileName, repoPath, { ref: ref }))) {
|
|
Logger.log(cc, `Skipping log; '${fileName}' is not tracked`);
|
|
return emptyPromise as Promise<GitLog>;
|
|
}
|
|
|
|
const [file, root] = Git.splitPath(fileName, repoPath, false);
|
|
|
|
try {
|
|
if (range != null && range.start.line > range.end.line) {
|
|
range = new Range(range.end, range.start);
|
|
}
|
|
|
|
const data = await Git.log__file(root, file, ref, {
|
|
...options,
|
|
firstParent: options.renames,
|
|
startLine: range == null ? undefined : range.start.line + 1,
|
|
endLine: range == null ? undefined : range.end.line + 1,
|
|
});
|
|
const log = GitLogParser.parse(
|
|
data,
|
|
GitCommitType.LogFile,
|
|
root,
|
|
file,
|
|
ref,
|
|
await this.getCurrentUser(root),
|
|
options.limit,
|
|
options.reverse!,
|
|
range,
|
|
);
|
|
|
|
if (log != null) {
|
|
const opts = { ...options, ref: ref, range: range };
|
|
log.query = (limit: number | undefined) =>
|
|
this.getLogForFile(repoPath, fileName, { ...opts, limit: limit });
|
|
if (log.hasMore) {
|
|
log.more = this.getLogForFileMoreFn(log, fileName, opts);
|
|
}
|
|
}
|
|
|
|
return log;
|
|
} catch (ex) {
|
|
// Trap and cache expected log errors
|
|
if (document.state != null && range == null && !options.reverse) {
|
|
const msg: string = ex?.toString() ?? '';
|
|
Logger.debug(cc, `Cache replace (with empty promise): '${key}'`);
|
|
|
|
const value: CachedLog = {
|
|
item: emptyPromise as Promise<GitLog>,
|
|
errorMessage: msg,
|
|
};
|
|
document.state.set<CachedLog>(key, value);
|
|
|
|
return emptyPromise as Promise<GitLog>;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private getLogForFileMoreFn(
|
|
log: GitLog,
|
|
fileName: string,
|
|
options: { all?: boolean; limit?: number; range?: Range; ref?: string; renames?: boolean; reverse?: boolean },
|
|
): (limit: number | { until: string } | undefined) => Promise<GitLog> {
|
|
return async (limit: number | { until: string } | undefined) => {
|
|
const moreUntil = limit != null && typeof limit === 'object' ? limit.until : undefined;
|
|
let moreLimit = typeof limit === 'number' ? limit : undefined;
|
|
|
|
if (moreUntil && Iterables.some(log.commits.values(), c => c.ref === moreUntil)) {
|
|
return log;
|
|
}
|
|
|
|
moreLimit = moreLimit ?? Container.config.advanced.maxSearchItems ?? 0;
|
|
|
|
const ref = Iterables.last(log.commits.values())?.ref;
|
|
const moreLog = await this.getLogForFile(log.repoPath, fileName, {
|
|
...options,
|
|
limit: moreUntil == null ? moreLimit : 0,
|
|
ref: options.all ? undefined : moreUntil == null ? `${ref}^` : `${moreUntil}^..${ref}^`,
|
|
skip: options.all ? log.count : undefined,
|
|
});
|
|
if (moreLog == null) {
|
|
// If we can't find any more, assume we have everything
|
|
return { ...log, hasMore: false };
|
|
}
|
|
|
|
// Merge authors
|
|
const authors = new Map([...log.authors]);
|
|
for (const [key, addAuthor] of moreLog.authors) {
|
|
const author = authors.get(key);
|
|
if (author == null) {
|
|
authors.set(key, addAuthor);
|
|
} else {
|
|
author.lineCount += addAuthor.lineCount;
|
|
}
|
|
}
|
|
|
|
const commits = new Map([...log.commits, ...moreLog.commits]);
|
|
|
|
const mergedLog: GitLog = {
|
|
repoPath: log.repoPath,
|
|
authors: authors,
|
|
commits: commits,
|
|
sha: log.sha,
|
|
range: log.range,
|
|
count: commits.size,
|
|
limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined,
|
|
hasMore: moreUntil == null ? moreLog.hasMore : true,
|
|
query: (limit: number | undefined) =>
|
|
this.getLogForFile(log.repoPath, fileName, { ...options, limit: limit }),
|
|
};
|
|
|
|
if (options.renames) {
|
|
const renamed = Iterables.find(
|
|
moreLog.commits.values(),
|
|
c => Boolean(c.originalFileName) && c.originalFileName !== fileName,
|
|
);
|
|
if (renamed != null) {
|
|
fileName = renamed.originalFileName!;
|
|
}
|
|
}
|
|
|
|
mergedLog.more = this.getLogForFileMoreFn(mergedLog, fileName, options);
|
|
|
|
return mergedLog;
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getMergeBase(repoPath: string, ref1: string, ref2: string, options: { forkPoint?: boolean } = {}) {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
try {
|
|
const data = await Git.merge_base(repoPath, ref1, ref2, options);
|
|
if (data == null) return undefined;
|
|
|
|
return data.split('\n')[0];
|
|
} catch (ex) {
|
|
Logger.error(ex, cc);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getNextDiffUris(
|
|
repoPath: string,
|
|
uri: Uri,
|
|
ref: string | undefined,
|
|
skip: number = 0,
|
|
): Promise<{ current: GitUri; next: GitUri | undefined; deleted?: boolean } | undefined> {
|
|
// If we have no ref (or staged ref) there is no next commit
|
|
if (ref == null || ref.length === 0) return undefined;
|
|
|
|
const fileName = GitUri.relativeTo(uri, repoPath);
|
|
|
|
if (GitRevision.isUncommittedStaged(ref)) {
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, ref),
|
|
next: GitUri.fromFile(fileName, repoPath, undefined),
|
|
};
|
|
}
|
|
|
|
const next = await this.getNextUri(repoPath, uri, ref, skip);
|
|
if (next == null) {
|
|
const status = await this.getStatusForFile(repoPath, fileName);
|
|
if (status != null) {
|
|
// If the file is staged, diff with the staged version
|
|
if (status.indexStatus != null) {
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, ref),
|
|
next: GitUri.fromFile(fileName, repoPath, GitRevision.uncommittedStaged),
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, ref),
|
|
next: GitUri.fromFile(fileName, repoPath, undefined),
|
|
};
|
|
}
|
|
|
|
return {
|
|
current:
|
|
skip === 0
|
|
? GitUri.fromFile(fileName, repoPath, ref)
|
|
: (await this.getNextUri(repoPath, uri, ref, skip - 1))!,
|
|
next: next,
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getNextUri(
|
|
repoPath: string,
|
|
uri: Uri,
|
|
ref?: string,
|
|
skip: number = 0,
|
|
// editorLine?: number
|
|
): Promise<GitUri | undefined> {
|
|
// If we have no ref (or staged ref) there is no next commit
|
|
if (ref == null || ref.length === 0 || GitRevision.isUncommittedStaged(ref)) return undefined;
|
|
|
|
let filters: GitDiffFilter[] | undefined;
|
|
if (ref === GitRevision.deletedOrMissing) {
|
|
// If we are trying to move next from a deleted or missing ref then get the first commit
|
|
ref = undefined;
|
|
filters = ['A'];
|
|
}
|
|
|
|
const fileName = GitUri.relativeTo(uri, repoPath);
|
|
let data = await Git.log__file(repoPath, fileName, ref, {
|
|
filters: filters,
|
|
limit: skip + 1,
|
|
// startLine: editorLine != null ? editorLine + 1 : undefined,
|
|
reverse: true,
|
|
format: 'simple',
|
|
});
|
|
if (data == null || data.length === 0) return undefined;
|
|
|
|
const [nextRef, file, status] = GitLogParser.parseSimple(data, skip);
|
|
// If the file was deleted, check for a possible rename
|
|
if (status === 'D') {
|
|
data = await Git.log__file(repoPath, '.', nextRef, {
|
|
filters: ['R', 'C'],
|
|
limit: 1,
|
|
// startLine: editorLine != null ? editorLine + 1 : undefined
|
|
format: 'simple',
|
|
});
|
|
if (data == null || data.length === 0) {
|
|
return GitUri.fromFile(file ?? fileName, repoPath, nextRef);
|
|
}
|
|
|
|
const [nextRenamedRef, renamedFile] = GitLogParser.parseSimpleRenamed(data, file ?? fileName);
|
|
return GitUri.fromFile(
|
|
renamedFile ?? file ?? fileName,
|
|
repoPath,
|
|
nextRenamedRef ?? nextRef ?? GitRevision.deletedOrMissing,
|
|
);
|
|
}
|
|
|
|
return GitUri.fromFile(file ?? fileName, repoPath, nextRef);
|
|
}
|
|
|
|
@log()
|
|
async getPreviousDiffUris(
|
|
repoPath: string,
|
|
uri: Uri,
|
|
ref: string | undefined,
|
|
skip: number = 0,
|
|
firstParent: boolean = false,
|
|
): Promise<{ current: GitUri; previous: GitUri | undefined } | undefined> {
|
|
if (ref === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
const fileName = GitUri.relativeTo(uri, repoPath);
|
|
|
|
// If we are at the working tree (i.e. no ref), we need to dig deeper to figure out where to go
|
|
if (ref == null || ref.length === 0) {
|
|
// First, check the file status to see if there is anything staged
|
|
const status = await this.getStatusForFile(repoPath, fileName);
|
|
if (status != null) {
|
|
// If the file is staged with working changes, diff working with staged (index)
|
|
// If the file is staged without working changes, diff staged with HEAD
|
|
if (status.indexStatus != null) {
|
|
// Backs up to get to HEAD
|
|
if (status.workingTreeStatus == null) {
|
|
skip++;
|
|
}
|
|
|
|
if (skip === 0) {
|
|
// Diff working with staged
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, undefined),
|
|
previous: GitUri.fromFile(fileName, repoPath, GitRevision.uncommittedStaged),
|
|
};
|
|
}
|
|
|
|
return {
|
|
// Diff staged with HEAD (or prior if more skips)
|
|
current: GitUri.fromFile(fileName, repoPath, GitRevision.uncommittedStaged),
|
|
previous: await this.getPreviousUri(repoPath, uri, ref, skip - 1, undefined, firstParent),
|
|
};
|
|
} else if (status.workingTreeStatus != null) {
|
|
if (skip === 0) {
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, undefined),
|
|
previous: await this.getPreviousUri(repoPath, uri, undefined, skip, undefined, firstParent),
|
|
};
|
|
}
|
|
}
|
|
} else if (skip === 0) {
|
|
skip++;
|
|
}
|
|
}
|
|
// If we are at the index (staged), diff staged with HEAD
|
|
else if (GitRevision.isUncommittedStaged(ref)) {
|
|
const current =
|
|
skip === 0
|
|
? GitUri.fromFile(fileName, repoPath, ref)
|
|
: (await this.getPreviousUri(repoPath, uri, undefined, skip - 1, undefined, firstParent))!;
|
|
if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
return {
|
|
current: current,
|
|
previous: await this.getPreviousUri(repoPath, uri, undefined, skip, undefined, firstParent),
|
|
};
|
|
}
|
|
|
|
// If we are at a commit, diff commit with previous
|
|
const current =
|
|
skip === 0
|
|
? GitUri.fromFile(fileName, repoPath, ref)
|
|
: (await this.getPreviousUri(repoPath, uri, ref, skip - 1, undefined, firstParent))!;
|
|
if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
return {
|
|
current: current,
|
|
previous: await this.getPreviousUri(repoPath, uri, ref, skip, undefined, firstParent),
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getPreviousLineDiffUris(
|
|
repoPath: string,
|
|
uri: Uri,
|
|
editorLine: number,
|
|
ref: string | undefined,
|
|
skip: number = 0,
|
|
): Promise<{ current: GitUri; previous: GitUri | undefined; line: number } | undefined> {
|
|
if (ref === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
let fileName = GitUri.relativeTo(uri, repoPath);
|
|
|
|
let previous;
|
|
|
|
// If we are at the working tree (i.e. no ref), we need to dig deeper to figure out where to go
|
|
if (ref == null || ref.length === 0) {
|
|
// First, check the blame on the current line to see if there are any working/staged changes
|
|
const gitUri = new GitUri(uri, repoPath);
|
|
|
|
const document = await workspace.openTextDocument(uri);
|
|
const blameLine = document.isDirty
|
|
? await this.getBlameForLineContents(gitUri, editorLine, document.getText())
|
|
: await this.getBlameForLine(gitUri, editorLine);
|
|
if (blameLine == null) return undefined;
|
|
|
|
// If line is uncommitted, we need to dig deeper to figure out where to go (because blame can't be trusted)
|
|
if (blameLine.commit.isUncommitted) {
|
|
// If the document is dirty (unsaved), use the status to determine where to go
|
|
if (document.isDirty) {
|
|
// Check the file status to see if there is anything staged
|
|
const status = await this.getStatusForFile(repoPath, fileName);
|
|
if (status != null) {
|
|
// If the file is staged, diff working with staged (index)
|
|
// If the file is not staged, diff working with HEAD
|
|
if (status.indexStatus != null) {
|
|
// Diff working with staged
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, undefined),
|
|
previous: GitUri.fromFile(fileName, repoPath, GitRevision.uncommittedStaged),
|
|
line: editorLine,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Diff working with HEAD (or prior if more skips)
|
|
return {
|
|
current: GitUri.fromFile(fileName, repoPath, undefined),
|
|
previous: await this.getPreviousUri(repoPath, uri, undefined, skip, editorLine),
|
|
line: editorLine,
|
|
};
|
|
}
|
|
|
|
// First, check if we have a diff in the working tree
|
|
let hunkLine = await this.getDiffForLine(gitUri, editorLine, undefined);
|
|
if (hunkLine == null) {
|
|
// Next, check if we have a diff in the index (staged)
|
|
hunkLine = await this.getDiffForLine(gitUri, editorLine, undefined, GitRevision.uncommittedStaged);
|
|
|
|
if (hunkLine != null) {
|
|
ref = GitRevision.uncommittedStaged;
|
|
} else {
|
|
skip++;
|
|
}
|
|
}
|
|
}
|
|
// If line is committed, diff with line ref with previous
|
|
else {
|
|
ref = blameLine.commit.sha;
|
|
fileName = blameLine.commit.fileName || (blameLine.commit.originalFileName ?? fileName);
|
|
uri = GitUri.resolveToUri(fileName, repoPath);
|
|
editorLine = blameLine.line.originalLine - 1;
|
|
|
|
if (skip === 0 && blameLine.commit.previousSha) {
|
|
previous = GitUri.fromFile(fileName, repoPath, blameLine.commit.previousSha);
|
|
}
|
|
}
|
|
} else {
|
|
if (GitRevision.isUncommittedStaged(ref)) {
|
|
const current =
|
|
skip === 0
|
|
? GitUri.fromFile(fileName, repoPath, ref)
|
|
: (await this.getPreviousUri(repoPath, uri, undefined, skip - 1, editorLine))!;
|
|
if (current.sha === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
return {
|
|
current: current,
|
|
previous: await this.getPreviousUri(repoPath, uri, undefined, skip, editorLine),
|
|
line: editorLine,
|
|
};
|
|
}
|
|
|
|
const gitUri = new GitUri(uri, { repoPath: repoPath, sha: ref });
|
|
const blameLine = await this.getBlameForLine(gitUri, editorLine);
|
|
if (blameLine == null) return undefined;
|
|
|
|
// Diff with line ref with previous
|
|
ref = blameLine.commit.sha;
|
|
fileName = blameLine.commit.fileName || (blameLine.commit.originalFileName ?? fileName);
|
|
uri = GitUri.resolveToUri(fileName, repoPath);
|
|
editorLine = blameLine.line.originalLine - 1;
|
|
|
|
if (skip === 0 && blameLine.commit.previousSha) {
|
|
previous = GitUri.fromFile(fileName, repoPath, blameLine.commit.previousSha);
|
|
}
|
|
}
|
|
|
|
const current =
|
|
skip === 0
|
|
? GitUri.fromFile(fileName, repoPath, ref)
|
|
: (await this.getPreviousUri(repoPath, uri, ref, skip - 1, editorLine))!;
|
|
if (current.sha === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
return {
|
|
current: current,
|
|
previous: previous ?? (await this.getPreviousUri(repoPath, uri, ref, skip, editorLine)),
|
|
line: editorLine,
|
|
};
|
|
}
|
|
|
|
@log()
|
|
async getPreviousUri(
|
|
repoPath: string,
|
|
uri: Uri,
|
|
ref?: string,
|
|
skip: number = 0,
|
|
editorLine?: number,
|
|
firstParent: boolean = false,
|
|
): Promise<GitUri | undefined> {
|
|
if (ref === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
if (ref === GitRevision.uncommitted) {
|
|
ref = undefined;
|
|
}
|
|
|
|
const fileName = GitUri.relativeTo(uri, repoPath);
|
|
// TODO: Add caching
|
|
let data;
|
|
try {
|
|
data = await Git.log__file(repoPath, fileName, ref, {
|
|
limit: skip + 2,
|
|
firstParent: firstParent,
|
|
format: 'simple',
|
|
startLine: editorLine != null ? editorLine + 1 : undefined,
|
|
});
|
|
} catch (ex) {
|
|
const msg: string = ex?.toString() ?? emptyStr;
|
|
// If the line count is invalid just fallback to the most recent commit
|
|
if ((ref == null || GitRevision.isUncommittedStaged(ref)) && GitErrors.invalidLineCount.test(msg)) {
|
|
if (ref == null) {
|
|
const status = await this.getStatusForFile(repoPath, fileName);
|
|
if (status?.indexStatus != null) {
|
|
return GitUri.fromFile(fileName, repoPath, GitRevision.uncommittedStaged);
|
|
}
|
|
}
|
|
|
|
ref = await Git.log__file_recent(repoPath, fileName);
|
|
return GitUri.fromFile(fileName, repoPath, ref ?? GitRevision.deletedOrMissing);
|
|
}
|
|
|
|
Logger.error(ex, cc);
|
|
throw ex;
|
|
}
|
|
if (data == null || data.length === 0) return undefined;
|
|
|
|
const [previousRef, file] = GitLogParser.parseSimple(data, skip, ref);
|
|
// If the previous ref matches the ref we asked for assume we are at the end of the history
|
|
if (ref != null && ref === previousRef) return undefined;
|
|
|
|
return GitUri.fromFile(file ?? fileName, repoPath, previousRef ?? GitRevision.deletedOrMissing);
|
|
}
|
|
|
|
async getPullRequestForBranch(
|
|
branch: string,
|
|
remote: GitRemote,
|
|
options?: { avatarSize?: number; include?: PullRequestState[]; limit?: number; timeout?: number },
|
|
): Promise<PullRequest | undefined>;
|
|
async getPullRequestForBranch(
|
|
branch: string,
|
|
provider: RichRemoteProvider,
|
|
options?: { avatarSize?: number; include?: PullRequestState[]; limit?: number; timeout?: number },
|
|
): Promise<PullRequest | undefined>;
|
|
@gate()
|
|
@debug<GitService['getPullRequestForBranch']>({
|
|
args: {
|
|
1: (remoteOrProvider: GitRemote | RichRemoteProvider) => remoteOrProvider.name,
|
|
},
|
|
})
|
|
async getPullRequestForBranch(
|
|
branch: string,
|
|
remoteOrProvider: GitRemote | RichRemoteProvider,
|
|
{
|
|
timeout,
|
|
...options
|
|
}: { avatarSize?: number; include?: PullRequestState[]; limit?: number; timeout?: number } = {},
|
|
): Promise<PullRequest | undefined> {
|
|
let provider;
|
|
if (GitRemote.is(remoteOrProvider)) {
|
|
({ provider } = remoteOrProvider);
|
|
if (!provider?.hasApi()) return undefined;
|
|
} else {
|
|
provider = remoteOrProvider;
|
|
}
|
|
|
|
let promiseOrPR = provider.getPullRequestForBranch(branch, options);
|
|
if (promiseOrPR == null || !Promises.is(promiseOrPR)) {
|
|
return promiseOrPR;
|
|
}
|
|
|
|
if (timeout != null && timeout > 0) {
|
|
promiseOrPR = Promises.cancellable(promiseOrPR, timeout);
|
|
}
|
|
|
|
try {
|
|
return await promiseOrPR;
|
|
} catch (ex) {
|
|
if (ex instanceof Promises.CancellationError) {
|
|
throw ex;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async getPullRequestForCommit(
|
|
ref: string,
|
|
remote: GitRemote,
|
|
options?: { timeout?: number },
|
|
): Promise<PullRequest | undefined>;
|
|
async getPullRequestForCommit(
|
|
ref: string,
|
|
provider: RichRemoteProvider,
|
|
options?: { timeout?: number },
|
|
): Promise<PullRequest | undefined>;
|
|
@gate()
|
|
@debug({
|
|
args: {
|
|
1: (remoteOrProvider: GitRemote | RichRemoteProvider) => remoteOrProvider.name,
|
|
},
|
|
})
|
|
async getPullRequestForCommit(
|
|
ref: string,
|
|
remoteOrProvider: GitRemote | RichRemoteProvider,
|
|
{ timeout }: { timeout?: number } = {},
|
|
): Promise<PullRequest | undefined> {
|
|
if (GitRevision.isUncommitted(ref)) return undefined;
|
|
|
|
let provider;
|
|
if (GitRemote.is(remoteOrProvider)) {
|
|
({ provider } = remoteOrProvider);
|
|
if (!provider?.hasApi()) return undefined;
|
|
} else {
|
|
provider = remoteOrProvider;
|
|
}
|
|
|
|
let promiseOrPR = provider.getPullRequestForCommit(ref);
|
|
if (promiseOrPR == null || !Promises.is(promiseOrPR)) {
|
|
return promiseOrPR;
|
|
}
|
|
|
|
if (timeout != null && timeout > 0) {
|
|
promiseOrPR = Promises.cancellable(promiseOrPR, timeout);
|
|
}
|
|
|
|
try {
|
|
return await promiseOrPR;
|
|
} catch (ex) {
|
|
if (ex instanceof Promises.CancellationError) {
|
|
throw ex;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getIncomingActivity(
|
|
repoPath: string,
|
|
{ limit, ...options }: { all?: boolean; branch?: string; limit?: number; skip?: number } = {},
|
|
): Promise<GitReflog | undefined> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
limit = limit ?? Container.config.advanced.maxListItems ?? 0;
|
|
try {
|
|
// Pass a much larger limit to reflog, because we aggregate the data and we won't know how many lines we'll need
|
|
const data = await Git.reflog(repoPath, { ...options, limit: limit * 100 });
|
|
if (data == null) return undefined;
|
|
|
|
const reflog = GitReflogParser.parse(data, repoPath, reflogCommands, limit, limit * 100);
|
|
if (reflog?.hasMore) {
|
|
reflog.more = this.getReflogMoreFn(reflog, options);
|
|
}
|
|
|
|
return reflog;
|
|
} catch (ex) {
|
|
Logger.error(ex, cc);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private getReflogMoreFn(
|
|
reflog: GitReflog,
|
|
options: { all?: boolean; branch?: string; limit?: number; skip?: number },
|
|
): (limit: number) => Promise<GitReflog> {
|
|
return async (limit: number | undefined) => {
|
|
limit = limit ?? Container.config.advanced.maxSearchItems ?? 0;
|
|
|
|
const moreLog = await this.getIncomingActivity(reflog.repoPath, {
|
|
...options,
|
|
limit: limit,
|
|
skip: reflog.total,
|
|
});
|
|
if (moreLog == null) {
|
|
// If we can't find any more, assume we have everything
|
|
return { ...reflog, hasMore: false };
|
|
}
|
|
|
|
const mergedLog: GitReflog = {
|
|
repoPath: reflog.repoPath,
|
|
records: [...reflog.records, ...moreLog.records],
|
|
count: reflog.count + moreLog.count,
|
|
total: reflog.total + moreLog.total,
|
|
limit: (reflog.limit ?? 0) + limit,
|
|
hasMore: moreLog.hasMore,
|
|
};
|
|
mergedLog.more = this.getReflogMoreFn(mergedLog, options);
|
|
|
|
return mergedLog;
|
|
};
|
|
}
|
|
|
|
async getRichRemoteProvider(
|
|
repoPath: string | undefined,
|
|
options?: { includeDisconnected?: boolean },
|
|
): Promise<GitRemote<RichRemoteProvider> | undefined>;
|
|
async getRichRemoteProvider(
|
|
remotes: GitRemote[],
|
|
options?: { includeDisconnected?: boolean },
|
|
): Promise<GitRemote<RichRemoteProvider> | undefined>;
|
|
@gate<GitService['getRichRemoteProvider']>(
|
|
(remotesOrRepoPath, options) =>
|
|
`${typeof remotesOrRepoPath === 'string' ? remotesOrRepoPath : remotesOrRepoPath[0]?.repoPath}:${
|
|
options?.includeDisconnected ?? false
|
|
}`,
|
|
)
|
|
@log({ args: { 0: () => false } })
|
|
async getRichRemoteProvider(
|
|
remotesOrRepoPath: GitRemote[] | string | undefined,
|
|
{ includeDisconnected }: { includeDisconnected?: boolean } = {},
|
|
): Promise<GitRemote<RichRemoteProvider> | undefined> {
|
|
if (remotesOrRepoPath == null) return undefined;
|
|
|
|
const cacheKey = `${includeDisconnected ? 'disconnected|' : ''}${
|
|
typeof remotesOrRepoPath === 'string' ? remotesOrRepoPath : remotesOrRepoPath[0]?.repoPath
|
|
}`;
|
|
if (cacheKey != null) {
|
|
const remote = this._remotesWithApiProviderCache.get(cacheKey);
|
|
if (remote !== undefined) return remote ?? undefined;
|
|
}
|
|
|
|
const remotes = (typeof remotesOrRepoPath === 'string'
|
|
? await this.getRemotes(remotesOrRepoPath)
|
|
: remotesOrRepoPath
|
|
).filter(r => r.provider != null);
|
|
|
|
let remote;
|
|
if (remotes.length === 1) {
|
|
remote = remotes[0];
|
|
} else {
|
|
let originRemote;
|
|
for (const r of remotes) {
|
|
if (r.default) {
|
|
remote = r;
|
|
break;
|
|
}
|
|
|
|
if (r.name === 'origin') {
|
|
originRemote = r;
|
|
}
|
|
}
|
|
|
|
remote = remote ?? originRemote ?? null;
|
|
}
|
|
|
|
if (!remote?.provider?.hasApi()) {
|
|
if (cacheKey != null) {
|
|
this._remotesWithApiProviderCache.set(cacheKey, null);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const { provider } = remote;
|
|
if (!includeDisconnected) {
|
|
const connected = provider.maybeConnected ?? (await provider.isConnected());
|
|
if (!connected) {
|
|
if (cacheKey != null) {
|
|
this._remotesWithApiProviderCache.set(cacheKey, null);
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
if (cacheKey != null) {
|
|
this._remotesWithApiProviderCache.set(cacheKey, remote as GitRemote<RichRemoteProvider>);
|
|
}
|
|
return remote as GitRemote<RichRemoteProvider>;
|
|
}
|
|
|
|
@log()
|
|
async getRemotes(
|
|
repoPath: string | undefined,
|
|
options: { sort?: boolean } = {},
|
|
): Promise<GitRemote<RemoteProvider>[]> {
|
|
if (repoPath == null) return [];
|
|
|
|
const repository = await this.getRepository(repoPath);
|
|
const remotes = await (repository != null
|
|
? repository.getRemotes({ sort: options.sort })
|
|
: this.getRemotesCore(repoPath, undefined, { sort: options.sort }));
|
|
|
|
return remotes.filter(r => r.provider != null) as GitRemote<RemoteProvider>[];
|
|
}
|
|
|
|
async getRemotesCore(
|
|
repoPath: string | undefined,
|
|
providers?: RemoteProviders,
|
|
options: { sort?: boolean } = {},
|
|
): Promise<GitRemote[]> {
|
|
if (repoPath == null) return [];
|
|
|
|
providers = providers ?? RemoteProviderFactory.loadProviders(configuration.get('remotes', null));
|
|
|
|
try {
|
|
const data = await Git.remote(repoPath);
|
|
const remotes = GitRemoteParser.parse(data, repoPath, RemoteProviderFactory.factory(providers));
|
|
if (remotes == null) return [];
|
|
|
|
if (options.sort) {
|
|
GitRemote.sort(remotes);
|
|
}
|
|
|
|
return remotes;
|
|
} catch (ex) {
|
|
Logger.error(ex);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getRepoPath(filePath: string, options?: { ref?: string }): Promise<string | undefined>;
|
|
async getRepoPath(uri: Uri | undefined, options?: { ref?: string }): Promise<string | undefined>;
|
|
@log<GitService['getRepoPath']>({
|
|
exit: path => `returned ${path}`,
|
|
})
|
|
async getRepoPath(
|
|
filePathOrUri: string | Uri | undefined,
|
|
options: { ref?: string } = {},
|
|
): Promise<string | undefined> {
|
|
if (filePathOrUri == null) return this.getHighlanderRepoPath();
|
|
if (GitUri.is(filePathOrUri)) return filePathOrUri.repoPath;
|
|
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
// Don't save the tracking info to the cache, because we could be looking in the wrong place (e.g. looking in the root when the file is in a submodule)
|
|
let repo = await this.getRepository(filePathOrUri, { ...options, skipCacheUpdate: true });
|
|
if (repo != null) return repo.path;
|
|
|
|
const rp = await this.getRepoPathCore(
|
|
typeof filePathOrUri === 'string' ? filePathOrUri : filePathOrUri.fsPath,
|
|
false,
|
|
);
|
|
if (rp == null) return undefined;
|
|
|
|
// Recheck this._repositoryTree.get(rp) to make sure we haven't already tried adding this due to awaits
|
|
if (this._repositoryTree.get(rp) != null) return rp;
|
|
|
|
const isVslsScheme =
|
|
typeof filePathOrUri === 'string' ? undefined : filePathOrUri.scheme === DocumentSchemes.Vsls;
|
|
|
|
// If this new repo is inside one of our known roots and we we don't already know about, add it
|
|
const root = this.findRepositoryForPath(this._repositoryTree, rp, isVslsScheme);
|
|
|
|
let folder;
|
|
if (root != null) {
|
|
// Not sure why I added this for vsls (I can't see a reason for it anymore), but if it is added it will break submodules
|
|
// rp = root.path;
|
|
folder = root.folder;
|
|
} else {
|
|
folder = workspace.getWorkspaceFolder(GitUri.file(rp, isVslsScheme));
|
|
if (folder == null) {
|
|
const parts = rp.split(slash);
|
|
folder = {
|
|
uri: GitUri.file(rp, isVslsScheme),
|
|
name: parts[parts.length - 1],
|
|
index: this._repositoryTree.count(),
|
|
};
|
|
}
|
|
}
|
|
|
|
Logger.log(cc, `Repository found in '${rp}'`);
|
|
repo = new Repository(folder, rp, false, this.onAnyRepositoryChanged.bind(this), !window.state.focused);
|
|
this._repositoryTree.set(rp, repo);
|
|
|
|
// Send a notification that the repositories changed
|
|
setImmediate(async () => {
|
|
await this.updateContext(this._repositoryTree);
|
|
|
|
this.fireRepositoriesChanged();
|
|
});
|
|
|
|
return rp;
|
|
}
|
|
|
|
@debug()
|
|
private async getRepoPathCore(filePath: string, isDirectory: boolean): Promise<string | undefined> {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
try {
|
|
const path = isDirectory ? filePath : paths.dirname(filePath);
|
|
|
|
let repoPath = await Git.rev_parse__show_toplevel(path);
|
|
if (repoPath == null) return repoPath;
|
|
|
|
if (isWindows) {
|
|
// On Git 2.25+ if you call `rev-parse --show-toplevel` on a mapped drive, instead of getting the mapped drive path back, you get the UNC path for the mapped drive.
|
|
// So try to normalize it back to the mapped drive path, if possible
|
|
|
|
const repoUri = Uri.file(repoPath);
|
|
const pathUri = Uri.file(path);
|
|
if (repoUri.authority.length !== 0 && pathUri.authority.length === 0) {
|
|
const match = driveLetterRegex.exec(pathUri.path);
|
|
if (match != null) {
|
|
const [, letter] = match;
|
|
|
|
try {
|
|
const networkPath = await new Promise<string | undefined>(resolve =>
|
|
fs.realpath.native(`${letter}:`, { encoding: 'utf8' }, (err, resolvedPath) =>
|
|
resolve(err != null ? undefined : resolvedPath),
|
|
),
|
|
);
|
|
if (networkPath != null) {
|
|
return Strings.normalizePath(
|
|
repoUri.fsPath.replace(
|
|
networkPath,
|
|
`${letter.toLowerCase()}:${networkPath.endsWith('\\') ? '\\' : ''}`,
|
|
),
|
|
);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return Strings.normalizePath(pathUri.fsPath);
|
|
}
|
|
|
|
return repoPath;
|
|
}
|
|
|
|
// If we are not on Windows (symlinks don't seem to have the same issue on Windows), check if we are a symlink and if so, use the symlink path (not its resolved path)
|
|
// This is because VS Code will provide document Uris using the symlinked path
|
|
return await new Promise<string | undefined>(resolve => {
|
|
fs.realpath(path, { encoding: 'utf8' }, (err, resolvedPath) => {
|
|
if (err != null) {
|
|
Logger.debug(cc, `fs.realpath failed; repoPath=${repoPath}`);
|
|
resolve(repoPath);
|
|
return;
|
|
}
|
|
|
|
if (path.toLowerCase() === resolvedPath.toLowerCase()) {
|
|
Logger.debug(cc, `No symlink detected; repoPath=${repoPath}`);
|
|
resolve(repoPath);
|
|
return;
|
|
}
|
|
|
|
const linkPath = Strings.normalizePath(resolvedPath, { stripTrailingSlash: true });
|
|
repoPath = repoPath!.replace(linkPath, path);
|
|
Logger.debug(
|
|
cc,
|
|
`Symlink detected; repoPath=${repoPath}, path=${path}, resolvedPath=${resolvedPath}`,
|
|
);
|
|
resolve(repoPath);
|
|
});
|
|
});
|
|
} catch (ex) {
|
|
Logger.error(ex, cc);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getRepoPathOrActive(uri: Uri | undefined, editor: TextEditor | undefined) {
|
|
const repoPath = await this.getRepoPath(uri);
|
|
if (repoPath) return repoPath;
|
|
|
|
return this.getActiveRepoPath(editor);
|
|
}
|
|
|
|
@log()
|
|
async getRepositories(predicate?: (repo: Repository) => boolean): Promise<Iterable<Repository>> {
|
|
const repositoryTree = await this.getRepositoryTree();
|
|
|
|
const values = repositoryTree.values();
|
|
return predicate != null ? Iterables.filter(values, predicate) : values;
|
|
}
|
|
|
|
@log()
|
|
async getOrderedRepositories(): Promise<Repository[]> {
|
|
const repositories = [...(await this.getRepositories())];
|
|
if (repositories.length === 0) return repositories;
|
|
|
|
return Repository.sort(repositories.filter(r => !r.closed));
|
|
}
|
|
|
|
private async getRepositoryTree(): Promise<TernarySearchTree<string, Repository>> {
|
|
if (this._repositoriesLoadingPromise != null) {
|
|
await this._repositoriesLoadingPromise;
|
|
this._repositoriesLoadingPromise = undefined;
|
|
}
|
|
|
|
return this._repositoryTree;
|
|
}
|
|
|
|
async getRepository(
|
|
repoPath: string,
|
|
options?: { ref?: string; skipCacheUpdate?: boolean },
|
|
): Promise<Repository | undefined>;
|
|
async getRepository(
|
|
uri: Uri,
|
|
options?: { ref?: string; skipCacheUpdate?: boolean },
|
|
): Promise<Repository | undefined>;
|
|
async getRepository(
|
|
repoPathOrUri: string | Uri,
|
|
options?: { ref?: string; skipCacheUpdate?: boolean },
|
|
): Promise<Repository | undefined>;
|
|
@log<GitService['getRepository']>({
|
|
exit: repo => `returned ${repo != null ? `${repo.path}` : 'undefined'}`,
|
|
})
|
|
async getRepository(
|
|
repoPathOrUri: string | Uri,
|
|
options: { ref?: string; skipCacheUpdate?: boolean } = {},
|
|
): Promise<Repository | undefined> {
|
|
const repositoryTree = await this.getRepositoryTree();
|
|
|
|
let isVslsScheme;
|
|
|
|
let path: string;
|
|
if (typeof repoPathOrUri === 'string') {
|
|
const repo = repositoryTree.get(repoPathOrUri);
|
|
if (repo != null) return repo;
|
|
|
|
path = repoPathOrUri;
|
|
isVslsScheme = undefined;
|
|
} else {
|
|
if (GitUri.is(repoPathOrUri)) {
|
|
if (repoPathOrUri.repoPath) {
|
|
const repo = repositoryTree.get(repoPathOrUri.repoPath);
|
|
if (repo != null) return repo;
|
|
}
|
|
|
|
path = repoPathOrUri.fsPath;
|
|
} else {
|
|
path = repoPathOrUri.fsPath;
|
|
}
|
|
|
|
isVslsScheme = repoPathOrUri.scheme === DocumentSchemes.Vsls;
|
|
}
|
|
|
|
const repo = this.findRepositoryForPath(repositoryTree, path, isVslsScheme);
|
|
if (repo == null) return undefined;
|
|
|
|
// Make sure the file is tracked in this repo before returning -- it could be from a submodule
|
|
if (!(await this.isTracked(path, repo.path, options))) return undefined;
|
|
return repo;
|
|
}
|
|
|
|
private findRepositoryForPath(
|
|
repositoryTree: TernarySearchTree<string, Repository>,
|
|
path: string,
|
|
isVslsScheme: boolean | undefined,
|
|
): Repository | undefined {
|
|
let repo = repositoryTree.findSubstr(path);
|
|
// If we can't find the repo and we are a guest, check if we are a "root" workspace
|
|
if (repo == null && isVslsScheme !== false && Container.vsls.isMaybeGuest) {
|
|
if (!vslsUriPrefixRegex.test(path)) {
|
|
path = Strings.normalizePath(path);
|
|
const vslsPath = `/~0${path.startsWith(slash) ? path : `/${path}`}`;
|
|
repo = repositoryTree.findSubstr(vslsPath);
|
|
}
|
|
}
|
|
return repo;
|
|
}
|
|
|
|
async getLocalInfoFromRemoteUri(
|
|
uri: Uri,
|
|
options?: { validate?: boolean },
|
|
): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> {
|
|
for (const repo of await this.getRepositories()) {
|
|
for (const remote of await repo.getRemotes()) {
|
|
const local = await remote?.provider?.getLocalInfoFromRemoteUri(repo, uri, options);
|
|
if (local != null) return local;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async getRepositoryCount(): Promise<number> {
|
|
const repositoryTree = await this.getRepositoryTree();
|
|
return repositoryTree.count();
|
|
}
|
|
|
|
@log()
|
|
async getStash(repoPath: string | undefined): Promise<GitStash | undefined> {
|
|
if (repoPath == null) return undefined;
|
|
|
|
const data = await Git.stash__list(repoPath, {
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
});
|
|
const stash = GitStashParser.parse(data, repoPath);
|
|
return stash;
|
|
}
|
|
|
|
@log()
|
|
async getStatusForFile(repoPath: string, fileName: string): Promise<GitStatusFile | undefined> {
|
|
const porcelainVersion = Git.validateVersion(2, 11) ? 2 : 1;
|
|
|
|
const data = await Git.status__file(repoPath, fileName, porcelainVersion, {
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
});
|
|
const status = GitStatusParser.parse(data, repoPath, porcelainVersion);
|
|
if (status == null || !status.files.length) return undefined;
|
|
|
|
return status.files[0];
|
|
}
|
|
|
|
@log()
|
|
async getStatusForRepo(repoPath: string | undefined): Promise<GitStatus | undefined> {
|
|
if (repoPath == null) return undefined;
|
|
|
|
const porcelainVersion = Git.validateVersion(2, 11) ? 2 : 1;
|
|
|
|
const data = await Git.status(repoPath, porcelainVersion, {
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
});
|
|
const status = GitStatusParser.parse(data, repoPath, porcelainVersion);
|
|
return status;
|
|
}
|
|
|
|
@log()
|
|
async getTags(
|
|
repoPath: string | undefined,
|
|
options: { filter?: (t: GitTag) => boolean; sort?: boolean | { orderBy?: TagSorting } } = {},
|
|
): Promise<GitTag[]> {
|
|
if (repoPath == null) return [];
|
|
|
|
let tags = this.useCaching ? this._tagsCache.get(repoPath) : undefined;
|
|
if (tags == null) {
|
|
const data = await Git.tag(repoPath);
|
|
tags = GitTagParser.parse(data, repoPath) ?? [];
|
|
|
|
const repo = await this.getRepository(repoPath);
|
|
if (repo?.supportsChangeEvents) {
|
|
this._tagsCache.set(repoPath, tags);
|
|
}
|
|
}
|
|
|
|
if (options.filter != null) {
|
|
tags = tags.filter(options.filter);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
|
if (options.sort) {
|
|
GitTag.sort(tags, typeof options.sort === 'boolean' ? undefined : options.sort);
|
|
}
|
|
|
|
return tags;
|
|
}
|
|
|
|
@log()
|
|
async getTreeFileForRevision(repoPath: string, fileName: string, ref: string): Promise<GitTree | undefined> {
|
|
if (repoPath == null || fileName == null || fileName.length === 0) return undefined;
|
|
|
|
const data = await Git.ls_tree(repoPath, ref, { fileName: fileName });
|
|
const trees = GitTreeParser.parse(data);
|
|
return trees?.length ? trees[0] : undefined;
|
|
}
|
|
|
|
@log()
|
|
async getTreeForRevision(repoPath: string, ref: string): Promise<GitTree[]> {
|
|
if (repoPath == null) return [];
|
|
|
|
const data = await Git.ls_tree(repoPath, ref);
|
|
return GitTreeParser.parse(data) ?? [];
|
|
}
|
|
|
|
@log()
|
|
getVersionedFileBuffer(repoPath: string, fileName: string, ref: string) {
|
|
return Git.show<Buffer>(repoPath, fileName, ref, { encoding: 'buffer' });
|
|
}
|
|
|
|
@log()
|
|
async getVersionedUri(
|
|
repoPath: string | undefined,
|
|
fileName: string,
|
|
ref: string | undefined,
|
|
): Promise<Uri | undefined> {
|
|
if (ref === GitRevision.deletedOrMissing) return undefined;
|
|
|
|
if (
|
|
ref == null ||
|
|
ref.length === 0 ||
|
|
(GitRevision.isUncommitted(ref) && !GitRevision.isUncommittedStaged(ref))
|
|
) {
|
|
// Make sure the file exists in the repo
|
|
let data = await Git.ls_files(repoPath!, fileName);
|
|
if (data != null) return GitUri.file(fileName);
|
|
|
|
// Check if the file exists untracked
|
|
data = await Git.ls_files(repoPath!, fileName, { untracked: true });
|
|
if (data != null) return GitUri.file(fileName);
|
|
|
|
return undefined;
|
|
}
|
|
|
|
if (GitRevision.isUncommittedStaged(ref)) {
|
|
return GitUri.git(fileName, repoPath);
|
|
}
|
|
|
|
return GitUri.toRevisionUri(ref, fileName, repoPath!);
|
|
}
|
|
|
|
@log()
|
|
async getWorkingUri(repoPath: string, uri: Uri) {
|
|
let fileName = GitUri.relativeTo(uri, repoPath);
|
|
|
|
let data;
|
|
let ref;
|
|
do {
|
|
data = await Git.ls_files(repoPath, fileName);
|
|
if (data != null) {
|
|
fileName = Strings.splitSingle(data, '\n')[0];
|
|
break;
|
|
}
|
|
|
|
// TODO: Add caching
|
|
// Get the most recent commit for this file name
|
|
ref = await Git.log__file_recent(repoPath, fileName, {
|
|
similarityThreshold: Container.config.advanced.similarityThreshold,
|
|
});
|
|
if (ref == null) return undefined;
|
|
|
|
// Now check if that commit had any renames
|
|
data = await Git.log__file(repoPath, '.', ref, {
|
|
filters: ['R', 'C', 'D'],
|
|
limit: 1,
|
|
format: 'simple',
|
|
});
|
|
if (data == null || data.length === 0) break;
|
|
|
|
const [foundRef, foundFile, foundStatus] = GitLogParser.parseSimpleRenamed(data, fileName);
|
|
if (foundStatus === 'D' && foundFile != null) return undefined;
|
|
if (foundRef == null || foundFile == null) break;
|
|
|
|
fileName = foundFile;
|
|
} while (true);
|
|
|
|
uri = GitUri.resolveToUri(fileName, repoPath);
|
|
return (await fsExists(uri.fsPath)) ? uri : undefined;
|
|
}
|
|
|
|
@log()
|
|
async hasBranchesAndOrTags(
|
|
repoPath: string | undefined,
|
|
{
|
|
filter,
|
|
}: {
|
|
filter?: { branches?: (b: GitBranch) => boolean; tags?: (t: GitTag) => boolean };
|
|
} = {},
|
|
) {
|
|
const [branches, tags] = await Promise.all<GitBranch[] | undefined, GitTag[] | undefined>([
|
|
this.getBranches(repoPath, {
|
|
filter: filter?.branches,
|
|
sort: false,
|
|
}),
|
|
this.getTags(repoPath, {
|
|
filter: filter?.tags,
|
|
sort: false,
|
|
}),
|
|
]);
|
|
|
|
return (branches != null && branches.length !== 0) || (tags != null && tags.length !== 0);
|
|
}
|
|
|
|
@log()
|
|
async hasRemotes(repoPath: string | undefined): Promise<boolean> {
|
|
if (repoPath == null) return false;
|
|
|
|
const repository = await this.getRepository(repoPath);
|
|
if (repository == null) return false;
|
|
|
|
return repository.hasRemotes();
|
|
}
|
|
|
|
@log()
|
|
async hasTrackingBranch(repoPath: string | undefined): Promise<boolean> {
|
|
if (repoPath == null) return false;
|
|
|
|
const repository = await this.getRepository(repoPath);
|
|
if (repository == null) return false;
|
|
|
|
return repository.hasTrackingBranch();
|
|
}
|
|
|
|
isTrackable(scheme: string): boolean;
|
|
isTrackable(uri: Uri): boolean;
|
|
isTrackable(schemeOruri: string | Uri): boolean {
|
|
const scheme = typeof schemeOruri === 'string' ? schemeOruri : schemeOruri.scheme;
|
|
return (
|
|
scheme === DocumentSchemes.File ||
|
|
scheme === DocumentSchemes.Git ||
|
|
scheme === DocumentSchemes.GitLens ||
|
|
scheme === DocumentSchemes.PRs ||
|
|
scheme === DocumentSchemes.Vsls
|
|
);
|
|
}
|
|
|
|
async isTracked(
|
|
fileName: string,
|
|
repoPath?: string,
|
|
options?: { ref?: string; skipCacheUpdate?: boolean },
|
|
): Promise<boolean>;
|
|
async isTracked(uri: GitUri): Promise<boolean>;
|
|
@log<GitService['isTracked']>({
|
|
exit: tracked => `returned ${tracked}`,
|
|
singleLine: true,
|
|
})
|
|
async isTracked(
|
|
fileNameOrUri: string | GitUri,
|
|
repoPath?: string,
|
|
options: { ref?: string; skipCacheUpdate?: boolean } = {},
|
|
): Promise<boolean> {
|
|
if (options.ref === GitRevision.deletedOrMissing) return false;
|
|
|
|
let ref = options.ref;
|
|
let cacheKey: string;
|
|
let fileName: string;
|
|
if (typeof fileNameOrUri === 'string') {
|
|
[fileName, repoPath] = Git.splitPath(fileNameOrUri, repoPath);
|
|
cacheKey = GitUri.toKey(fileNameOrUri);
|
|
} else {
|
|
if (!this.isTrackable(fileNameOrUri)) return false;
|
|
|
|
fileName = fileNameOrUri.fsPath;
|
|
repoPath = fileNameOrUri.repoPath;
|
|
ref = fileNameOrUri.sha;
|
|
cacheKey = GitUri.toKey(fileName);
|
|
}
|
|
|
|
if (ref != null) {
|
|
cacheKey += `:${ref}`;
|
|
}
|
|
|
|
let tracked = this._trackedCache.get(cacheKey);
|
|
if (tracked != null) {
|
|
tracked = await tracked;
|
|
|
|
return tracked;
|
|
}
|
|
|
|
tracked = this.isTrackedCore(fileName, repoPath == null ? emptyStr : repoPath, ref);
|
|
if (options.skipCacheUpdate) {
|
|
tracked = await tracked;
|
|
|
|
return tracked;
|
|
}
|
|
|
|
this._trackedCache.set(cacheKey, tracked);
|
|
tracked = await tracked;
|
|
this._trackedCache.set(cacheKey, tracked);
|
|
|
|
return tracked;
|
|
}
|
|
|
|
private async isTrackedCore(fileName: string, repoPath: string, ref?: string) {
|
|
if (ref === GitRevision.deletedOrMissing) return false;
|
|
|
|
try {
|
|
// Even if we have a ref, check first to see if the file exists (that way the cache will be better reused)
|
|
let tracked = Boolean(await Git.ls_files(repoPath == null ? emptyStr : repoPath, fileName));
|
|
if (!tracked && ref != null) {
|
|
tracked = Boolean(await Git.ls_files(repoPath == null ? emptyStr : repoPath, fileName, { ref: ref }));
|
|
// If we still haven't found this file, make sure it wasn't deleted in that ref (i.e. check the previous)
|
|
if (!tracked) {
|
|
tracked = Boolean(
|
|
await Git.ls_files(repoPath == null ? emptyStr : repoPath, fileName, {
|
|
ref: `${ref}^`,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
return tracked;
|
|
} catch (ex) {
|
|
Logger.error(ex);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@log()
|
|
async getDiffTool(repoPath?: string) {
|
|
return (
|
|
(await Git.config__get('diff.guitool', repoPath, { local: true })) ??
|
|
Git.config__get('diff.tool', repoPath, { local: true })
|
|
);
|
|
}
|
|
|
|
@log()
|
|
async openDiffTool(
|
|
repoPath: string,
|
|
uri: Uri,
|
|
options: { ref1?: string; ref2?: string; staged?: boolean; tool?: string } = {},
|
|
) {
|
|
if (!options.tool) {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
options.tool = await this.getDiffTool(repoPath);
|
|
if (options.tool == null) throw new Error('No diff tool found');
|
|
|
|
Logger.log(cc, `Using tool=${options.tool}`);
|
|
}
|
|
|
|
const { tool, ...opts } = options;
|
|
return Git.difftool(repoPath, uri.fsPath, tool, opts);
|
|
}
|
|
|
|
@log()
|
|
async openDirectoryCompare(repoPath: string, ref1: string, ref2?: string, tool?: string) {
|
|
if (!tool) {
|
|
const cc = Logger.getCorrelationContext();
|
|
|
|
tool = await this.getDiffTool(repoPath);
|
|
if (tool == null) throw new Error('No diff tool found');
|
|
|
|
Logger.log(cc, `Using tool=${tool}`);
|
|
}
|
|
|
|
return Git.difftool__dir_diff(repoPath, tool, ref1, ref2);
|
|
}
|
|
|
|
async resolveReference(
|
|
repoPath: string,
|
|
ref: string,
|
|
fileName?: string,
|
|
options?: { timeout?: number },
|
|
): Promise<string>;
|
|
async resolveReference(repoPath: string, ref: string, uri?: Uri, options?: { timeout?: number }): Promise<string>;
|
|
@log()
|
|
async resolveReference(
|
|
repoPath: string,
|
|
ref: string,
|
|
fileNameOrUri?: string | Uri,
|
|
options?: { timeout?: number },
|
|
) {
|
|
if (ref == null || ref.length === 0 || ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) {
|
|
return ref;
|
|
}
|
|
|
|
if (fileNameOrUri == null) {
|
|
if (GitRevision.isSha(ref) || !GitRevision.isShaLike(ref) || ref.endsWith('^3')) return ref;
|
|
|
|
return (await Git.rev_parse__verify(repoPath, ref)) ?? ref;
|
|
}
|
|
|
|
const fileName =
|
|
typeof fileNameOrUri === 'string'
|
|
? fileNameOrUri
|
|
: Strings.normalizePath(paths.relative(repoPath, fileNameOrUri.fsPath));
|
|
|
|
const blob = await Git.rev_parse__verify(repoPath, ref, fileName);
|
|
if (blob == null) return GitRevision.deletedOrMissing;
|
|
|
|
let promise: Promise<string | void | undefined> = Git.log__find_object(repoPath, blob, ref, fileName);
|
|
if (options?.timeout != null) {
|
|
promise = Promise.race([promise, Functions.wait(options.timeout)]);
|
|
}
|
|
|
|
return (await promise) ?? ref;
|
|
}
|
|
|
|
@log()
|
|
validateBranchOrTagName(ref: string, repoPath?: string): Promise<boolean> {
|
|
return Git.check_ref_format(ref, repoPath);
|
|
}
|
|
|
|
@log()
|
|
async validateReference(repoPath: string, ref: string) {
|
|
if (ref == null || ref.length === 0) return false;
|
|
if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return true;
|
|
|
|
return (await Git.rev_parse__verify(repoPath, ref)) != null;
|
|
}
|
|
|
|
stageFile(repoPath: string, fileName: string): Promise<string>;
|
|
stageFile(repoPath: string, uri: Uri): Promise<string>;
|
|
@log()
|
|
stageFile(repoPath: string, fileNameOrUri: string | Uri): Promise<string> {
|
|
return Git.add(
|
|
repoPath,
|
|
typeof fileNameOrUri === 'string' ? fileNameOrUri : Git.splitPath(fileNameOrUri.fsPath, repoPath)[0],
|
|
);
|
|
}
|
|
|
|
stageDirectory(repoPath: string, directory: string): Promise<string>;
|
|
stageDirectory(repoPath: string, uri: Uri): Promise<string>;
|
|
@log()
|
|
stageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise<string> {
|
|
return Git.add(
|
|
repoPath,
|
|
typeof directoryOrUri === 'string' ? directoryOrUri : Git.splitPath(directoryOrUri.fsPath, repoPath)[0],
|
|
);
|
|
}
|
|
|
|
unStageFile(repoPath: string, fileName: string): Promise<string>;
|
|
unStageFile(repoPath: string, uri: Uri): Promise<string>;
|
|
@log()
|
|
unStageFile(repoPath: string, fileNameOrUri: string | Uri): Promise<string> {
|
|
return Git.reset(
|
|
repoPath,
|
|
typeof fileNameOrUri === 'string' ? fileNameOrUri : Git.splitPath(fileNameOrUri.fsPath, repoPath)[0],
|
|
);
|
|
}
|
|
|
|
unStageDirectory(repoPath: string, directory: string): Promise<string>;
|
|
unStageDirectory(repoPath: string, uri: Uri): Promise<string>;
|
|
@log()
|
|
unStageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise<string> {
|
|
return Git.reset(
|
|
repoPath,
|
|
typeof directoryOrUri === 'string' ? directoryOrUri : Git.splitPath(directoryOrUri.fsPath, repoPath)[0],
|
|
);
|
|
}
|
|
|
|
@log()
|
|
stashApply(repoPath: string, stashName: string, { deleteAfter }: { deleteAfter?: boolean } = {}) {
|
|
return Git.stash__apply(repoPath, stashName, Boolean(deleteAfter));
|
|
}
|
|
|
|
@log()
|
|
stashDelete(repoPath: string, stashName: string, ref?: string) {
|
|
return Git.stash__delete(repoPath, stashName, ref);
|
|
}
|
|
|
|
@log()
|
|
stashSave(
|
|
repoPath: string,
|
|
message?: string,
|
|
uris?: Uri[],
|
|
options: { includeUntracked?: boolean; keepIndex?: boolean } = {},
|
|
) {
|
|
if (uris == null) return Git.stash__push(repoPath, message, options);
|
|
|
|
GitService.ensureGitVersion('2.13.2', 'Stashing individual files');
|
|
|
|
const pathspecs = uris.map(u => `./${Git.splitPath(u.fsPath, repoPath)[0]}`);
|
|
return Git.stash__push(repoPath, message, { ...options, pathspecs: pathspecs });
|
|
}
|
|
|
|
static compareGitVersion(version: string) {
|
|
return Versions.compare(Versions.fromString(Git.getGitVersion()), Versions.fromString(version));
|
|
}
|
|
|
|
static ensureGitVersion(version: string, feature: string): void {
|
|
const gitVersion = Git.getGitVersion();
|
|
if (Versions.compare(Versions.fromString(gitVersion), Versions.fromString(version)) === -1) {
|
|
throw new Error(
|
|
`${feature} requires a newer version of Git (>= ${version}) than is currently installed (${gitVersion}). Please install a more recent version of Git to use this GitLens feature.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
@log()
|
|
static async getBuiltInGitApi(): Promise<BuiltInGitApi | undefined> {
|
|
try {
|
|
const extension = extensions.getExtension('vscode.git') as Extension<GitExtension>;
|
|
if (extension != null) {
|
|
const gitExtension = extension.isActive ? extension.exports : await extension.activate();
|
|
|
|
return gitExtension.getAPI(1);
|
|
}
|
|
} catch {}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
@log()
|
|
static async getBuiltInGitRepository(repoPath: string): Promise<BuiltInGitRepository | undefined> {
|
|
const gitApi = await GitService.getBuiltInGitApi();
|
|
if (gitApi == null) return undefined;
|
|
|
|
const normalizedPath = Strings.normalizePath(repoPath, { stripTrailingSlash: true }).toLowerCase();
|
|
|
|
const repo = gitApi.repositories.find(
|
|
r => Strings.normalizePath(r.rootUri.fsPath, { stripTrailingSlash: true }).toLowerCase() === normalizedPath,
|
|
);
|
|
|
|
return repo;
|
|
}
|
|
|
|
static getEncoding(repoPath: string, fileName: string): string;
|
|
static getEncoding(uri: Uri): string;
|
|
static getEncoding(repoPathOrUri: string | Uri, fileName?: string): string {
|
|
const uri = typeof repoPathOrUri === 'string' ? GitUri.resolveToUri(fileName!, repoPathOrUri) : repoPathOrUri;
|
|
return Git.getEncoding(configuration.getAny<string>('files.encoding', uri));
|
|
}
|
|
}
|