From 4891fb04b79d28a17b9b6e35ca2514fb6a23f933 Mon Sep 17 00:00:00 2001 From: Smartsheet-JB-Brown Date: Sat, 12 Apr 2025 23:12:40 -0700 Subject: [PATCH] feat: implement git-based lastUpdated dates --- src/services/package-manager/GitFetcher.ts | 18 +- .../package-manager/MetadataScanner.ts | 29 +++- .../__tests__/GitDateTracking.test.ts | 161 ++++++++++++++++++ 3 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 src/services/package-manager/__tests__/GitDateTracking.test.ts diff --git a/src/services/package-manager/GitFetcher.ts b/src/services/package-manager/GitFetcher.ts index b28dd78d2a..57459f8e71 100644 --- a/src/services/package-manager/GitFetcher.ts +++ b/src/services/package-manager/GitFetcher.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode" import * as path from "path" import * as fs from "fs/promises" import * as yaml from "js-yaml" -import simpleGit from "simple-git" +import simpleGit, { SimpleGit } from "simple-git" import { MetadataScanner } from "./MetadataScanner" import { validateAnyMetadata } from "./schemas" import { PackageManagerItem, PackageManagerRepository, RepositoryMetadata } from "./types" @@ -12,13 +12,24 @@ import { PackageManagerItem, PackageManagerRepository, RepositoryMetadata } from */ export class GitFetcher { private readonly cacheDir: string - private readonly metadataScanner: MetadataScanner + private metadataScanner: MetadataScanner + private git?: SimpleGit constructor(context: vscode.ExtensionContext) { this.cacheDir = path.join(context.globalStorageUri.fsPath, "package-manager-cache") this.metadataScanner = new MetadataScanner() } + /** + * Initialize git instance for a repository + * @param repoDir Repository directory + */ + private initGit(repoDir: string): void { + this.git = simpleGit(repoDir) + // Update MetadataScanner with new git instance + this.metadataScanner = new MetadataScanner(this.git) + } + /** * Fetch repository data * @param repoUrl Repository URL @@ -41,6 +52,9 @@ export class GitFetcher { // Clone or pull repository await this.cloneOrPullRepository(repoUrl, repoDir, forceRefresh) + // Initialize git for this repository + this.initGit(repoDir) + // Validate repository structure await this.validateRepositoryStructure(repoDir) diff --git a/src/services/package-manager/MetadataScanner.ts b/src/services/package-manager/MetadataScanner.ts index 89d78b4eee..4ff6f739fc 100644 --- a/src/services/package-manager/MetadataScanner.ts +++ b/src/services/package-manager/MetadataScanner.ts @@ -2,6 +2,7 @@ import * as path from "path" import * as fs from "fs/promises" import * as vscode from "vscode" import * as yaml from "js-yaml" +import { SimpleGit } from "simple-git" import { validateAnyMetadata } from "./schemas" import { ComponentMetadata, ComponentType, LocalizedMetadata, PackageManagerItem, PackageMetadata } from "./types" @@ -9,6 +10,12 @@ import { ComponentMetadata, ComponentType, LocalizedMetadata, PackageManagerItem * Handles component discovery and metadata loading */ export class MetadataScanner { + private readonly git?: SimpleGit + + constructor(git?: SimpleGit) { + this.git = git + } + /** * Scans a directory for components * @param rootDir The root directory to scan @@ -138,11 +145,31 @@ export class MetadataScanner { } /** - * Gets the last modified date for a component + * Gets the last modified date for a component using git history * @param componentDir The component directory * @returns ISO date string */ private async getLastModifiedDate(componentDir: string): Promise { + if (this.git) { + try { + // Get the latest commit date for the directory and its contents + const result = await this.git.raw([ + "log", + "-1", + "--format=%aI", // ISO 8601 format + "--", + componentDir, + ]) + if (result) { + return result.trim() + } + } catch (error) { + console.error(`Error getting git history for ${componentDir}:`, error) + // Fall through to fs.stat fallback + } + } + + // Fallback to fs.stat if git is not available or fails try { const stats = await fs.stat(componentDir) return stats.mtime.toISOString() diff --git a/src/services/package-manager/__tests__/GitDateTracking.test.ts b/src/services/package-manager/__tests__/GitDateTracking.test.ts new file mode 100644 index 0000000000..08e6c63e69 --- /dev/null +++ b/src/services/package-manager/__tests__/GitDateTracking.test.ts @@ -0,0 +1,161 @@ +import * as fs from "fs/promises" +import { Dirent, Stats } from "fs" +import { SimpleGit } from "simple-git" +import { MetadataScanner } from "../MetadataScanner" + +// Mock fs/promises +jest.mock("fs/promises") + +// Mock simple-git +jest.mock("simple-git", () => { + const mockGit = { + raw: jest.fn(), + } + return jest.fn(() => mockGit) +}) + +// Create mock Dirent objects +const createMockDirent = (name: string, isDir: boolean): Dirent => { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + // These are readonly in the real Dirent + path: "", + parentPath: "", + } as Dirent +} + +describe("Git Date Tracking", () => { + let metadataScanner: MetadataScanner + let mockGit: jest.Mocked + const mockFs = fs as jest.Mocked + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + + // Setup git mock + mockGit = { + raw: jest.fn(), + } as unknown as jest.Mocked + + metadataScanner = new MetadataScanner(mockGit) + }) + + it("should use git log date when available", async () => { + const mockDate = "2025-04-12T22:08:02-07:00" + mockGit.raw.mockResolvedValue(mockDate) + + // Mock directory structure + mockFs.readdir.mockImplementation((path: any, options?: any) => { + return Promise.resolve([createMockDirent("component1", true)]) + }) + + mockFs.readFile.mockImplementation((path: any) => { + return Promise.resolve(` +name: Test Component +description: A test component +type: mcp server +version: 1.0.0 +`) + }) + + const items = await metadataScanner.scanDirectory("/test/repo", "https://example.com") + + expect(items).toHaveLength(1) + expect(items[0].lastUpdated).toBe(mockDate) + expect(mockGit.raw).toHaveBeenCalledWith([ + "log", + "-1", + "--format=%aI", + "--", + expect.stringContaining("component1"), + ]) + }) + + it("should fall back to fs.stat when git log fails", async () => { + const mockDate = new Date() + mockGit.raw.mockRejectedValue(new Error("Git error")) + + // Mock fs.stat to return a specific date + const mockStats = { + mtime: mockDate, + isFile: () => false, + isDirectory: () => true, + dev: 0, + ino: 0, + mode: 0, + nlink: 0, + uid: 0, + gid: 0, + rdev: 0, + size: 0, + blksize: 0, + blocks: 0, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, + atime: new Date(), + ctime: new Date(), + birthtime: new Date(), + } as Stats + + mockFs.stat.mockResolvedValue(mockStats) + + // Mock directory structure + mockFs.readdir.mockImplementation((path: any, options?: any) => { + return Promise.resolve([createMockDirent("component1", true)]) + }) + + mockFs.readFile.mockImplementation((path: any) => { + return Promise.resolve(` +name: Test Component +description: A test component +type: mcp server +version: 1.0.0 +`) + }) + + const items = await metadataScanner.scanDirectory("/test/repo", "https://example.com") + + expect(items).toHaveLength(1) + expect(items[0].lastUpdated).toBe(mockDate.toISOString()) + expect(mockFs.stat).toHaveBeenCalled() + }) + + it("should fall back to current date when both git and fs.stat fail", async () => { + const beforeTest = new Date() + mockGit.raw.mockRejectedValue(new Error("Git error")) + mockFs.stat.mockRejectedValue(new Error("Stat error")) + + // Mock directory structure + mockFs.readdir.mockImplementation((path: any, options?: any) => { + return Promise.resolve([createMockDirent("component1", true)]) + }) + + mockFs.readFile.mockImplementation((path: any) => { + return Promise.resolve(` +name: Test Component +description: A test component +type: mcp server +version: 1.0.0 +`) + }) + + const items = await metadataScanner.scanDirectory("/test/repo", "https://example.com") + const afterTest = new Date() + + expect(items).toHaveLength(1) + expect(items[0].lastUpdated).toBeDefined() + const lastUpdated = new Date(items[0].lastUpdated!) + expect(lastUpdated.getTime()).toBeGreaterThanOrEqual(beforeTest.getTime()) + expect(lastUpdated.getTime()).toBeLessThanOrEqual(afterTest.getTime()) + }) +})