feat: implement git-based lastUpdated dates

This commit is contained in:
Smartsheet-JB-Brown 2025-04-12 23:12:40 -07:00
parent 3e8d35b490
commit 4891fb04b7
3 changed files with 205 additions and 3 deletions

View file

@ -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)

View file

@ -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<string> {
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()

View file

@ -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<SimpleGit>
const mockFs = fs as jest.Mocked<typeof fs>
beforeEach(() => {
// Reset mocks
jest.clearAllMocks()
// Setup git mock
mockGit = {
raw: jest.fn(),
} as unknown as jest.Mocked<SimpleGit>
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())
})
})