From 1a8cbcaf34c1f09637d71711dae68864f544e0ef Mon Sep 17 00:00:00 2001 From: Tanmay Pachpande Date: Fri, 22 May 2026 10:00:55 +1000 Subject: [PATCH] Release 0.11.0 with managed template generation Add multi-template .gitignore generation that preserves custom rules, detects existing managed or legacy template content, and renders templates in deterministic alphabetical order so repeated selections produce reproducible files. Include templates from the GitHub gitignore community folder, cache downloaded template bodies using repository content SHAs when available, and refresh provider tests for current upstream template contents. Bump extension metadata and lockfile to version 0.11.0. --- package-lock.json | 14 +- package.json | 2 +- src/extension.ts | 296 +++++++++++++++---- src/interfaces.ts | 7 +- src/providers/github-gitignore-repository.ts | 38 ++- src/test/extension.test.ts | 129 ++++++-- src/test/providers/github-gitignore.test.ts | 73 ++--- 7 files changed, 423 insertions(+), 136 deletions(-) diff --git a/package-lock.json b/package-lock.json index f49b726..c040240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitignore", - "version": "0.10.0-alpha.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitignore", - "version": "0.10.0-alpha.1", + "version": "0.11.0", "license": "MIT", "dependencies": { "https-proxy-agent": "^7.0.6" @@ -15,7 +15,7 @@ "@eslint/js": "^9.21.0", "@types/mocha": "^10.0.10", "@types/node": "^22.13.5", - "@types/vscode": ">=1.63.0 <1.64.0", + "@types/vscode": ">=1.66.0 <1.67.0", "@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/parser": "^8.22.0", "@vscode/test-cli": "^0.0.10", @@ -28,7 +28,7 @@ "typescript-eslint": "^8.25.0" }, "engines": { - "vscode": "^1.63.0" + "vscode": "^1.66.0" } }, "node_modules/@azure/abort-controller": { @@ -1007,9 +1007,9 @@ } }, "node_modules/@types/vscode": { - "version": "1.63.2", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.63.2.tgz", - "integrity": "sha512-awvdx4vX7SkMKyvWIlRjycjb4blYRSQI3Bav0YMn+lJLGN6gJgb20urN/dQCv/2ejDu5S6ADEBt6O15DOpIAkg==", + "version": "1.66.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.66.0.tgz", + "integrity": "sha512-ZfJck4M7nrGasfs4A4YbUoxis3Vu24cETw3DERsNYtDZmYSYtk6ljKexKFKhImO/ZmY6ZMsmegu2FPkXoUFImA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 79edd0d..55b3332 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitignore", "displayName": "gitignore", "description": "Lets you pull .gitignore templates from the https://github.com/github/gitignore repository. Language support for .gitignore files.", - "version": "0.10.0", + "version": "0.11.0", "author": "Marc-André Bühler", "publisher": "codezombiech", "icon": "icon.png", diff --git a/src/extension.ts b/src/extension.ts index 31518b1..6da7874 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; -import { join as joinPath } from 'path'; +import { dirname, join as joinPath } from 'path'; import { Cache } from './cache'; import { GitignoreTemplate, GitignoreOperation, GitignoreOperationType, GitignoreProvider } from './interfaces'; @@ -17,6 +17,27 @@ interface GitignoreQuickPickItem extends vscode.QuickPickItem { template: GitignoreTemplate; } +export interface GitignoreTemplateContent { + template: GitignoreTemplate; + content: string; +} + +export interface GitignoreFileState { + customContent: string; + templateIds: Set; +} + +const separator = '# ----------------------------------------------------------------------'; +const customRulesHeader = '# Custom ignore rules'; +const templatesHeader = '# GitHub gitignore templates'; +const templateHeaderPrefix = '# Template: '; +const templateSourcePrefix = '# Source: github/gitignore/'; +const templateStartPrefix = '# >>> vscode-gitignore template: '; +const templateSummaryPrefix = '# Templates: '; +const customRulesInstruction = '# Add project-specific ignore rules in this section. The extension preserves this content.'; +const legacyCustomRulesHeader = '# === vscode-gitignore custom rules ==='; +const legacyTemplatesHeader = '# === vscode-gitignore templates ==='; + // Initialize cache // The cache is the only instance shared across the whole lifetime of the extension @@ -50,7 +71,7 @@ function createNeverUsedCache() : Cache { * - using the single opened workspace * - prompting for the workspace to use when multiple workspaces are open */ -async function resolveWorkspaceFolder(gitIgnoreTemplate: GitignoreTemplate) { +async function resolveWorkspaceFolderPath() { const folders = vscode.workspace.workspaceFolders; // folders being falsy can have two reasons: // 1. no folder (workspace) open @@ -61,14 +82,14 @@ async function resolveWorkspaceFolder(gitIgnoreTemplate: GitignoreTemplate) { throw new CancellationError(); } else if (folders.length === 1) { - return { template: gitIgnoreTemplate, path: folders[0].uri.fsPath }; + return folders[0].uri.fsPath; } else { const folder = await vscode.window.showWorkspaceFolderPick(); if (!folder) { throw new CancellationError(); } - return { template: gitIgnoreTemplate, path: folder.uri.fsPath }; + return folder.uri.fsPath; } } @@ -84,41 +105,18 @@ function checkIfFileExists(path: string) { }); } -async function checkExistenceAndPromptForOperation(path: string, template: GitignoreTemplate): Promise { - path = joinPath(path, '.gitignore'); - - const exists = await checkIfFileExists(path); - if (!exists) { - // File does not exists -> we are fine to create it - return { path, template, type: GitignoreOperationType.Overwrite }; - } - - const operation = await promptForOperation(); - if (!operation) { - throw new CancellationError(); - } - const typedString = operation.label; - const type = GitignoreOperationType[typedString]; - - return { path, template, type }; -} - export async function downloadGitignoreFile(gitignoreRepository: GitignoreProvider, operation: GitignoreOperation) { - const flags = operation.type === GitignoreOperationType.Overwrite ? 'w' : 'a'; - const fileStream = fs.createWriteStream(operation.path, { flags: flags }); - - // If appending to the existing .gitignore file, write a NEWLINE as separator - if(flags === 'a') { - fileStream.write('\n'); - } + const existingContent = await readFileIfExists(operation.path); + const templateContents = await downloadTemplateContents(gitignoreRepository, operation.templates, dirname(operation.path)); + const state = getGitignoreFileState(existingContent, templateContents); + const content = renderGitignoreFile(state.customContent, templateContents); try { - // Store the file on file system - await gitignoreRepository.downloadToStream(operation.template.path, fileStream); + await fs.promises.writeFile(operation.path, content, {encoding: 'utf8'}); } catch(error) { // Delete the .gitignore file if we created it - if(flags === 'w') { + if(operation.type === GitignoreOperationType.Overwrite) { fs.unlink(operation.path, err => { if(err) { console.error(`vscode-gitignore: ${err.message}`); @@ -129,25 +127,203 @@ export async function downloadGitignoreFile(gitignoreRepository: GitignoreProvid } } -function promptForOperation() { - return vscode.window.showQuickPick([ - { - label: 'Append', - description: 'Append to existing .gitignore file' - }, - { - label: 'Overwrite', - description: 'Overwrite existing .gitignore file' +async function readFileIfExists(path: string): Promise { + if (!await checkIfFileExists(path)) { + return ''; + } + return fs.promises.readFile(path, {encoding: 'utf8'}); +} + +function normalizeLineEndings(content: string) { + return content.replace(/\r\n?/g, '\n'); +} + +async function downloadTemplateContent(gitignoreRepository: GitignoreProvider, template: GitignoreTemplate, directory: string): Promise { + const tempFileName = `.gitignore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`; + const tempPath = joinPath(directory, tempFileName); + const fileStream = fs.createWriteStream(tempPath, { flags: 'w' }); + + try { + await gitignoreRepository.downloadToStream(template.path, fileStream); + const content = await fs.promises.readFile(tempPath, {encoding: 'utf8'}); + return { template, content: normalizeLineEndings(content).trimEnd() }; + } + finally { + await fs.promises.rm(tempPath, { force: true }); + } +} + +async function downloadTemplateContents(gitignoreRepository: GitignoreProvider, templates: GitignoreTemplate[], directory: string): Promise { + const contents: GitignoreTemplateContent[] = []; + const queue = sortTemplates(templates); + const workerCount = Math.min(6, queue.length); + const workers = Array.from({length: workerCount}, async () => { + let template = queue.shift(); + while (template) { + contents.push(await downloadTemplateContent(gitignoreRepository, template, directory)); + template = queue.shift(); } - ]); + }); + + await Promise.all(workers); + return contents.sort((a, b) => compareTemplates(a.template, b.template)); +} + +function compareTemplates(a: GitignoreTemplate, b: GitignoreTemplate) { + const nameComparison = a.name.localeCompare(b.name); + if (nameComparison !== 0) { + return nameComparison; + } + return a.path.localeCompare(b.path); +} + +function sortTemplates(templates: T[]): T[] { + return [...templates].sort(compareTemplates); +} + +function getTemplateId(template: GitignoreTemplate) { + return template.path || template.name; +} + +function getTemplateMarkerNames(content: string) { + const names = new Set(); + for (const line of normalizeLineEndings(content).split('\n')) { + if (line.startsWith(templateStartPrefix)) { + names.add(line.slice(templateStartPrefix.length).trim()); + } + } + return names; +} + +function getTemplateMarkerIds(content: string) { + const ids = new Set(); + for (const line of normalizeLineEndings(content).split('\n')) { + if (line.startsWith(templateSourcePrefix)) { + ids.add(line.slice(templateSourcePrefix.length).trim()); + } + } + return ids; +} + +function stripLegacyTemplateContent(customContent: string, templateContents: GitignoreTemplateContent[]) { + let nextContent = normalizeLineEndings(customContent); + for (const templateContent of templateContents) { + const content = templateContent.content.trim(); + if (content.length > 0) { + nextContent = nextContent.replace(content, ''); + } + } + return nextContent.replace(/\n{3,}/g, '\n\n').trim(); +} + +function getManagedCustomContent(content: string) { + const normalizedContent = normalizeLineEndings(content); + const headerPair = [ + { custom: customRulesHeader, templates: templatesHeader }, + { custom: legacyCustomRulesHeader, templates: legacyTemplatesHeader } + ].find(headers => { + const customStart = normalizedContent.indexOf(headers.custom); + const templateStart = normalizedContent.indexOf(headers.templates); + return customStart !== -1 && templateStart !== -1 && templateStart > customStart; + }); + + if (!headerPair) { + return undefined; + } + + const customStart = normalizedContent.indexOf(headerPair.custom); + const templateStart = normalizedContent.indexOf(headerPair.templates); + + return normalizedContent + .slice(customStart + headerPair.custom.length, templateStart) + .replace(/^\n/, '') + .replace(new RegExp(`^${separator}\\n?`), '') + .replace(customRulesInstruction, '') + .trim(); +} + +export function getGitignoreFileState(content: string, templateContents: GitignoreTemplateContent[]): GitignoreFileState { + const normalizedContent = normalizeLineEndings(content); + const managedCustomContent = getManagedCustomContent(normalizedContent); + const legacyTemplateNames = getTemplateMarkerNames(normalizedContent); + const templateIds = getTemplateMarkerIds(normalizedContent); + for (const templateName of legacyTemplateNames) { + templateIds.add(templateName); + } + const detectedTemplateContents = templateContents.filter(templateContent => { + const templateContentText = templateContent.content.trim(); + return templateIds.has(getTemplateId(templateContent.template)) || + legacyTemplateNames.has(templateContent.template.name) || + (templateContentText.length > 0 && normalizedContent.includes(templateContentText)); + }); + + for (const templateContent of detectedTemplateContents) { + templateIds.add(getTemplateId(templateContent.template)); + } + + const customContent = managedCustomContent ?? stripLegacyTemplateContent(normalizedContent, detectedTemplateContents); + return { customContent, templateIds }; +} + +export function renderGitignoreFile(customContent: string, templateContents: GitignoreTemplateContent[]) { + const custom = customContent.trim(); + const sortedTemplateContents = [...templateContents].sort((a, b) => compareTemplates(a.template, b.template)); + const templateNames = sortedTemplateContents.map(templateContent => templateContent.template.name).join(', ') || 'none'; + const parts = [ + separator, + customRulesHeader, + separator, + customRulesInstruction, + custom, + '', + separator, + templatesHeader, + `${templateSummaryPrefix}${templateNames}`, + separator + ].filter((part, index) => index !== 4 || part.length > 0); + + for (const templateContent of sortedTemplateContents) { + parts.push( + '', + separator, + `${templateHeaderPrefix}${templateContent.template.name}`, + `${templateSourcePrefix}${getTemplateId(templateContent.template)}`, + separator, + templateContent.content.trim() + ); + } + + return `${parts.join('\n')}\n`; +} + +async function promptForTemplates(items: GitignoreQuickPickItem[], selectedTemplateIds: Set): Promise { + return new Promise((resolve, reject) => { + const quickPick = vscode.window.createQuickPick(); + quickPick.items = items; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.placeholder = 'Select .gitignore templates'; + quickPick.selectedItems = items.filter(item => selectedTemplateIds.has(getTemplateId(item.template)) || selectedTemplateIds.has(item.template.name)); + + quickPick.onDidAccept(() => { + const selectedItems = [...quickPick.selectedItems]; + quickPick.hide(); + resolve(selectedItems.map(item => item.template)); + }); + quickPick.onDidHide(() => { + quickPick.dispose(); + reject(new CancellationError()); + }); + quickPick.show(); + }); } function showSuccessMessage(operation: GitignoreOperation) { switch (operation.type) { - case GitignoreOperationType.Append: - return vscode.window.showInformationMessage(`Appended ${operation.template.path} to the existing .gitignore in the project root`); + case GitignoreOperationType.Update: + return vscode.window.showInformationMessage('Updated the existing .gitignore in the project root'); case GitignoreOperationType.Overwrite: - return vscode.window.showInformationMessage(`Created .gitignore file in the project root based on ${operation.template.path}`); + return vscode.window.showInformationMessage('Created .gitignore file in the project root'); default: throw new Error('Unsupported operation'); } @@ -174,27 +350,37 @@ export function activate(context: vscode.ExtensionContext) { // Load templates const templates = await gitignoreRepository.getTemplates(); + // Resolve the path to the folder where we should write the gitignore file + const workspacePath = await resolveWorkspaceFolderPath(); + const gitignorePath = joinPath(workspacePath, '.gitignore'); + const exists = await checkIfFileExists(gitignorePath); + const existingContent = await readFileIfExists(gitignorePath); + const existingTemplateContents = exists && getTemplateMarkerNames(existingContent).size < 1 && getTemplateMarkerIds(existingContent).size < 1 + ? await downloadTemplateContents(gitignoreRepository, templates, workspacePath) + : []; + const existingState = getGitignoreFileState(existingContent, existingTemplateContents); + // Let the user pick a gitignore file - const items = templates.map(t => { + const items = sortTemplates(templates).map(t => { label: t.name, description: t.path, url: t.download_url, template: t }); - // TODO: use thenable for items - const selectedItem = await vscode.window.showQuickPick(items); + const selectedTemplates = await promptForTemplates(items, existingState.templateIds); // Check if the user picked up a gitignore file fetched from Github - if (!selectedItem) { + if (selectedTemplates.length < 1) { throw new CancellationError(); } - // Resolve the path to the folder where we should write the gitignore file - const { template, path } = await resolveWorkspaceFolder(selectedItem.template); - // Calculate operation - console.log(`vscode-gitignore: add/append gitignore for directory: ${path}`); - const operation = await checkExistenceAndPromptForOperation(path, template); + console.log(`vscode-gitignore: update gitignore for directory: ${workspacePath}`); + const operation = { + path: gitignorePath, + templates: sortTemplates(selectedTemplates), + type: exists ? GitignoreOperationType.Update : GitignoreOperationType.Overwrite + }; // Store the file on file system await downloadGitignoreFile(gitignoreRepository, operation); diff --git a/src/interfaces.ts b/src/interfaces.ts index 7202d22..13f4c21 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -6,6 +6,7 @@ export interface GitignoreTemplate { path: string; download_url: string; type: string; + sha?: string; } export interface GitignoreProvider { @@ -14,7 +15,7 @@ export interface GitignoreProvider { } export enum GitignoreOperationType { - Append, + Update, Overwrite } @@ -25,7 +26,7 @@ export interface GitignoreOperation { */ path: string; /** - * gitignore template file to use + * gitignore template files to use */ - template: GitignoreTemplate; + templates: GitignoreTemplate[]; } diff --git a/src/providers/github-gitignore-repository.ts b/src/providers/github-gitignore-repository.ts index 118d22a..31c1aa5 100644 --- a/src/providers/github-gitignore-repository.ts +++ b/src/providers/github-gitignore-repository.ts @@ -14,6 +14,7 @@ interface GithubRepositoryItem { path: string; download_url: string; type: string; + sha: string; } /** @@ -22,6 +23,7 @@ interface GithubRepositoryItem { */ export class GithubGitignoreRepositoryProvider implements GitignoreProvider { private client: GitHubClient; + private templateShaByPath = new Map(); constructor(private cache: Cache, githubSession: GithubSession) { this.client = new GitHubClient(githubSession); @@ -34,7 +36,8 @@ export class GithubGitignoreRepositoryProvider implements GitignoreProvider { // Get lists of .gitignore files from Github const result = await Promise.all([ this.getFiles(), - this.getFiles('Global') + this.getFiles('Global'), + this.getFiles('community') ]); const files = (Array.prototype.concat.apply([], result) as GitignoreTemplate[]) .sort((a: GitignoreTemplate, b: GitignoreTemplate) => a.name.localeCompare(b.name)); @@ -48,6 +51,11 @@ export class GithubGitignoreRepositoryProvider implements GitignoreProvider { // If cached, return cached content const item = this.cache.get('gitignore/' + path) as GitignoreTemplate[]; if(typeof item !== 'undefined') { + item.forEach(template => { + if (template.sha) { + this.templateShaByPath.set(template.path, template.sha); + } + }); return item; } @@ -74,9 +82,11 @@ export class GithubGitignoreRepositoryProvider implements GitignoreProvider { return (item.type === 'file' && item.name.endsWith('.gitignore')); }) .map(item => { + this.templateShaByPath.set(item.path, item.sha); return { name: item.name.replace(/\.gitignore/, ''), - path: item.path + path: item.path, + sha: item.sha }; }); @@ -90,6 +100,19 @@ export class GithubGitignoreRepositoryProvider implements GitignoreProvider { * Downloads a .gitignore from the repository to the path passed */ public async downloadToStream(templatePath: string, writeStream: WriteStream): Promise { + const sha = this.templateShaByPath.get(templatePath); + const cacheKey = `gitignore/template/${templatePath}/${sha ?? 'latest'}`; + const cachedContent = this.cache.get(cacheKey) as string; + if (typeof cachedContent !== 'undefined') { + return new Promise(resolve => { + writeStream.on('finish', () => { + writeStream.close(); + resolve(); + }); + writeStream.end(cachedContent); + }); + } + /* curl \ -H "Accept: application/vnd.github.v3.raw" \ @@ -102,7 +125,14 @@ export class GithubGitignoreRepositoryProvider implements GitignoreProvider { headers: {...await this.client.getHeaders(), 'Accept': 'application/vnd.github.v3.raw'} }; - - await this.client.requestWriteStream(fullUrl, options, writeStream); + const responseBody = await this.client.requestString(fullUrl, options); + this.cache.add(new CacheItem(cacheKey, responseBody)); + return new Promise(resolve => { + writeStream.on('finish', () => { + writeStream.close(); + resolve(); + }); + writeStream.end(responseBody); + }); } } diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 21924b8..722f177 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -9,12 +9,26 @@ import assert from 'assert'; class GitignoreProviderMock implements GitignoreProvider { getTemplates(): Promise { - return Promise.resolve([{ - download_url :'', - name: 'example', - path: 'example', - type: 'foo' - }]); + return Promise.resolve([ + { + download_url :'', + name: 'example', + path: 'example', + type: 'foo' + }, + { + download_url :'', + name: 'Python', + path: 'Python.gitignore', + type: 'foo' + }, + { + download_url :'', + name: 'Node', + path: 'Node.gitignore', + type: 'foo' + } + ]); } downloadToStream(templatePath: string, writeStream: fs.WriteStream): Promise { return new Promise((resolve) => { @@ -35,14 +49,44 @@ function assertLines(path: string, ...expectedLines: string[]) { const content = fs.readFileSync(path, {encoding: 'utf8'}); const lines = content.split(/\r?\n/); - for (let i = 0; i < lines.length; ++i) { + assert.strictEqual(lines.length, expectedLines.length); + for (let i = 0; i < expectedLines.length; ++i) { const expected = expectedLines[i]; const got = lines[i]; console.log(`excepted: "${expected}", got: "${got}"`); - assert(expected === got); + assert.strictEqual(got, expected); } } +function expectedManagedLines(customRules: string[], templateNames: string[], templatePaths: string[]) { + const lines = [ + '# ----------------------------------------------------------------------', + '# Custom ignore rules', + '# ----------------------------------------------------------------------', + '# Add project-specific ignore rules in this section. The extension preserves this content.', + ...customRules, + '', + '# ----------------------------------------------------------------------', + '# GitHub gitignore templates', + `# Templates: ${templateNames.join(', ')}`, + '# ----------------------------------------------------------------------' + ]; + + for (let i = 0; i < templateNames.length; ++i) { + lines.push( + '', + '# ----------------------------------------------------------------------', + `# Template: ${templateNames[i]}`, + `# Source: github/gitignore/${templatePaths[i]}`, + '# ----------------------------------------------------------------------', + templatePaths[i] + ); + } + + lines.push(''); + return lines; +} + suite('Extension Test Suite', () => { // test('Sample test', async () => { @@ -59,7 +103,7 @@ suite('Extension Test Suite', () => { const templates = await gitignoreProvider.getTemplates(); const operation = { - template: templates[0], + templates: [templates[0]], path: path, type: GitignoreOperationType.Overwrite }; @@ -69,7 +113,10 @@ suite('Extension Test Suite', () => { const content = fs.readFileSync(path, {encoding: 'utf8'}); console.log(content); - assertLines(path, 'example', ''); + assertLines( + path, + ...expectedManagedLines([], ['example'], ['example']) + ); // Cleanup // if(fs.existsSync(path)) { @@ -77,7 +124,7 @@ suite('Extension Test Suite', () => { // } }); - test('can overwrite a gitignore file', async () => { + test('can convert an existing gitignore file and preserve custom rules', async () => { const testBaseDir = await createTmpTestDir('download'); const path = `${testBaseDir}/.gitignore`; fs.writeFileSync(path, "existing line"); @@ -87,14 +134,17 @@ suite('Extension Test Suite', () => { const templates = await gitignoreProvider.getTemplates(); const operation = { - template: templates[0], + templates: [templates[0]], path: path, type: GitignoreOperationType.Overwrite }; await downloadGitignoreFile(gitignoreProvider, operation); - assertLines(path, 'example', ''); + assertLines( + path, + ...expectedManagedLines(['existing line'], ['example'], ['example']) + ); // Cleanup if(fs.existsSync(path)) { @@ -102,23 +152,26 @@ suite('Extension Test Suite', () => { } }); - test('can append to a gitignore file', async () => { + test('can update a gitignore file without duplicating existing template content', async () => { const testBaseDir = await createTmpTestDir('download'); const path = `${testBaseDir}/.gitignore`; - fs.writeFileSync(path, "existing line\n"); + fs.writeFileSync(path, "existing line\nexample\n"); const gitignoreProvider = new GitignoreProviderMock(); const templates = await gitignoreProvider.getTemplates(); const operation = { - template: templates[0], + templates: [templates[0]], path: path, - type: GitignoreOperationType.Append + type: GitignoreOperationType.Update }; await downloadGitignoreFile(gitignoreProvider, operation); - assertLines(path, 'existing line', '', 'example',''); + assertLines( + path, + ...expectedManagedLines(['existing line'], ['example'], ['example']) + ); // Cleanup if(fs.existsSync(path)) { @@ -126,4 +179,44 @@ suite('Extension Test Suite', () => { } }); + test('writes selected templates in a reproducible order', async () => { + const firstTestBaseDir = await createTmpTestDir('download'); + const secondTestBaseDir = await createTmpTestDir('download'); + const firstPath = `${firstTestBaseDir}/.gitignore`; + const secondPath = `${secondTestBaseDir}/.gitignore`; + + const gitignoreProvider = new GitignoreProviderMock(); + const templates = await gitignoreProvider.getTemplates(); + const python = templates.find(template => template.name === 'Python'); + const node = templates.find(template => template.name === 'Node'); + assert(python !== undefined); + assert(node !== undefined); + + await downloadGitignoreFile(gitignoreProvider, { + templates: [node, python], + path: firstPath, + type: GitignoreOperationType.Overwrite + }); + await downloadGitignoreFile(gitignoreProvider, { + templates: [python, node], + path: secondPath, + type: GitignoreOperationType.Overwrite + }); + + const firstContent = fs.readFileSync(firstPath, {encoding: 'utf8'}); + const secondContent = fs.readFileSync(secondPath, {encoding: 'utf8'}); + assert.strictEqual(firstContent, secondContent); + assertLines( + firstPath, + ...expectedManagedLines([], ['Node', 'Python'], ['Node.gitignore', 'Python.gitignore']) + ); + + if(fs.existsSync(firstPath)) { + fs.unlinkSync(firstPath); + } + if(fs.existsSync(secondPath)) { + fs.unlinkSync(secondPath); + } + }); + }); diff --git a/src/test/providers/github-gitignore.test.ts b/src/test/providers/github-gitignore.test.ts index b62cae9..fc03674 100644 --- a/src/test/providers/github-gitignore.test.ts +++ b/src/test/providers/github-gitignore.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import { Writable } from 'stream'; import { Cache } from '../../cache'; -import { GitignoreProvider, GitignoreOperation, GitignoreTemplate, GitignoreOperationType } from '../../interfaces'; +import { GitignoreProvider, GitignoreTemplate } from '../../interfaces'; import { GithubGitignoreApiProvider } from '../../providers/github-gitignore-api'; import { GithubGitignoreRepositoryProvider } from '../../providers/github-gitignore-repository'; import { GithubContext, GithubSession } from '../../github/session'; @@ -84,20 +84,17 @@ providers.forEach(provider => { fs.unlinkSync(path); } - const operation = { - template: templates.find(t => t.name === 'C'), - path: path, - type: GitignoreOperationType.Overwrite - }; + const template = templates.find(t => t.name === 'C'); + assert(template !== undefined); - const fileStream = fs.createWriteStream(operation.path, { flags: "w" }); - await provider.downloadToStream(operation.template.path, fileStream); + const fileStream = fs.createWriteStream(path, { flags: "w" }); + await provider.downloadToStream(template.path, fileStream); // Assert - const fileExists = await fileExits(operation.path); + const fileExists = await fileExits(path); assert(fileExists); - const content = fs.readFileSync(operation.path, {encoding: 'utf8'}); + const content = fs.readFileSync(path, {encoding: 'utf8'}); const lines = content.split(/\r?\n/); assert(lines[0] === '# Prerequisites'); @@ -113,16 +110,11 @@ providers.forEach(provider => { test('can download a root template (regular) to a writable stream', async () => { const memoryStream = new MemoryWritable(); - const path = provider.constructor.name + '.gitignore'; - - const operation = { - template: templates.find(t => t.name === 'Python'), - path: path, - type: GitignoreOperationType.Overwrite - }; + const template = templates.find(t => t.name === 'Python'); + assert(template !== undefined); // Act - await provider.downloadToStream(operation.template.path, memoryStream); + await provider.downloadToStream(template.path, memoryStream); // Assert const content = memoryStream.content; @@ -130,7 +122,7 @@ providers.forEach(provider => { assert(lines[0] === '# Byte-compiled / optimized / DLL files'); assert(lines[1] === '__pycache__/'); - assert(lines[2] === '*.py[cod]'); + assert(lines[2] === '*.py[codz]'); }); // Test for bug #21 @@ -138,16 +130,11 @@ providers.forEach(provider => { test('can download a root template (symlink) to a writable stream', async () => { const memoryStream = new MemoryWritable(); - const path = provider.constructor.name + '.gitignore'; - - const operation = { - template: templates.find(t => t.name === 'Clojure'), - path: path, - type: GitignoreOperationType.Overwrite - }; + const template = templates.find(t => t.name === 'Clojure'); + assert(template !== undefined); // Act - await provider.downloadToStream(operation.template.path, memoryStream); + await provider.downloadToStream(template.path, memoryStream); // Assert const content = memoryStream.content; @@ -170,24 +157,19 @@ providers.forEach(provider => { const memoryStream = new MemoryWritable(); - const path = provider.constructor.name + '.gitignore'; - - const operation = { - template: templates.find(t => t.name === 'VisualStudioCode'), - path: path, - type: GitignoreOperationType.Overwrite - }; + const template = templates.find(t => t.name === 'VisualStudioCode'); + assert(template !== undefined); // Act - await provider.downloadToStream(operation.template.path, memoryStream); + await provider.downloadToStream(template.path, memoryStream); // Assert const content = memoryStream.content; const lines = content.split(/\r?\n/); - assert(lines[0] === '.vscode/*'); - assert(lines[1] === '!.vscode/settings.json'); - assert(lines[2] === '!.vscode/tasks.json'); + assert(lines[0] === '# Visual Studio Code'); + assert(lines[1] === '.vscode/*'); + assert(lines[2] === '!.vscode/settings.json'); }); // Test for bug #21 @@ -201,16 +183,11 @@ providers.forEach(provider => { const memoryStream = new MemoryWritable(); - const path = provider.constructor.name + '.gitignore'; - - const operation = { - template: templates.find(t => t.name === 'Octave'), - path: path, - type: GitignoreOperationType.Overwrite - }; + const template = templates.find(t => t.name === 'Octave'); + assert(template !== undefined); // Act - await provider.downloadToStream(operation.template.path, memoryStream); + await provider.downloadToStream(template.path, memoryStream); // Assert const content = memoryStream.content; @@ -219,9 +196,9 @@ providers.forEach(provider => { // Ensure the content is not the name of the linked file assert(lines[0] !== 'MATLAB.gitignore'); - assert(lines[0] === '# Windows default autosave extension'); + assert(lines[0] === '# Autosave files'); assert(lines[1] === '*.asv'); - assert(lines[2] === ''); + assert(lines[2] === '*.m~'); }); });