Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(content-utils): Add hook for changing the commit history as it is read #175

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-dancers-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@inox-tools/content-utils': minor
---

Add new hook for inspecting, modifying and skipping commits as the history is processed.
15 changes: 14 additions & 1 deletion examples/content-injection/astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { defineConfig } from 'astro/config';
import integration from './integration';
import runtimeLogger from '@inox-tools/runtime-logger';
import contentUtils from '@inox-tools/content-utils';

const ignoreBefore = Date.parse('2024-08-01') / 1000;

// https://astro.build/config
export default defineConfig({
integrations: [runtimeLogger(), integration()],
integrations: [
runtimeLogger(),
contentUtils({
onCommit: ({ commitInfo, drop }) => {
if (commitInfo.secondsSinceEpoch < ignoreBefore) {
drop();
}
},
}),
integration(),
],
});
28 changes: 14 additions & 14 deletions packages/aik-route-config/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
coverage: {
all: true,
reportsDirectory: './__coverage__',
thresholds: {
autoUpdate: true,
lines: 53.98,
functions: 71.42,
branches: 82.75,
statements: 53.98,
},
},
},
});
test: {
coverage: {
all: true,
reportsDirectory: './__coverage__',
thresholds: {
autoUpdate: true,
lines: 53.98,
functions: 71.42,
branches: 82.75,
statements: 53.98,
},
},
},
});
20 changes: 19 additions & 1 deletion packages/content-utils/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { injectorPlugin } from './injectorPlugin.js';
import { seedCollections, type SeedCollectionsOptions } from './seedCollections.js';
import { gitBuildPlugin, gitDevPlugin } from './gitPlugin.js';
import { debug } from '../internal/debug.js';
import { z } from 'astro/zod';

export type InjectCollectionOptions = {
/**
Expand All @@ -27,7 +28,21 @@ export type InjectCollectionOptions = {
export const integration = withApi(
defineIntegration({
name: '@inox-tools/content-utils',
setup: () => {
optionsSchema: z
.object({
onCommit: z
.function(
z.tuple([
z.custom<
Parameters<NonNullable<Astro.IntegrationHooks['@it/content:git:commit']>>[0]
>(),
]),
z.custom<Promise<void> | void>()
)
.optional(),
})
.default({}),
setup: ({ options }) => {
debug('Generating empty state');
const state = emptyState();
const collectionSeedBuffer: SeedCollectionsOptions[] = [];
Expand Down Expand Up @@ -99,6 +114,9 @@ export const integration = withApi(
seedCollections(state, seedOptions);
}
},
'@it/content:git:commit': async (params) => {
return options.onCommit?.(params);
},
},
...api,
};
Expand Down
88 changes: 58 additions & 30 deletions packages/content-utils/src/runtime/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { sep, resolve, relative } from 'node:path';
import { hooks } from '@inox-tools/modular-station/hooks';
import { getDebug } from '../internal/debug.js';
import type { GitTrackingInfo } from '@it-astro:content/git';
import type { GitCommitInfo, GitTrackingInfo } from '@it-astro:content/git';

let contentPath: string = '';

Expand Down Expand Up @@ -51,7 +51,7 @@ export async function collectGitInfoForContentFiles(): Promise<[string, RawGitTr

const args = [
'log',
'--format=t:%ct %an <%ae>|%(trailers:key=co-authored-by,valueonly,separator=|)',
'--format=cmt:%H(%h) %ct %an <%ae>|%(trailers:key=co-authored-by,valueonly,separator=|)',
'--name-status',
'--',
contentPath,
Expand All @@ -68,37 +68,65 @@ export async function collectGitInfoForContentFiles(): Promise<[string, RawGitTr
return [];
}

const parsingState = {
date: 0,
author: <GitAuthor>{ name: '', email: '' },
coAuthors: <GitAuthor[]>[],
let skipping = false;
const parsingState: GitCommitInfo = {
hash: '',
shortHash: '',
secondsSinceEpoch: 0,
author: { name: '', email: '' },
coAuthors: [],
};

const fileInfos = new Map<string, RawGitTrackingInfo>();

for (const logLine of gitLog.stdout.split('\n')) {
if (logLine.startsWith('t:')) {
// t:<seconds since epoch> <author name> <author email>|<co-authors>
const firstSpace = logLine.indexOf(' ');
parsingState.date = Number.parseInt(logLine.slice(2, firstSpace)) * 1000;

const authors = logLine
.slice(firstSpace + 1)
.replace(/\|$/, '')
.split('|')
.map((author) => {
const [name, email] = author.split('<');
return {
name: name.trim(),
email: email.slice(0, -1),
};
});
parsingState.author = authors[0];
parsingState.coAuthors = authors.slice(1);
if (logLine.startsWith('cmt:')) {
// New commit, stop skipping
skipping = false;

const headerLineMatch = logLine.match(
/^cmt:(?<hash>[a-f0-9]+)\((?<shortHash>[a-f0-9]+)\) (?<epoch>\d+) (?<authorName>[^<]+) <(?<authorEmail>[^>]+)>\|(?<coAuthors>.*?)$/
);
if (!headerLineMatch) {
// Skip data from unparseable commit
skipping = true;
continue;
}

const groups = headerLineMatch.groups!;

parsingState.hash = groups.hash;
parsingState.shortHash = groups.shortHash;
parsingState.secondsSinceEpoch = Number.parseInt(groups.epoch);
parsingState.author.name = groups.authorName;
parsingState.author.email = groups.authorEmail;
parsingState.coAuthors = groups.coAuthors.split('|').map((author) => {
const [name, email] = author.split('<');
return {
name: name.trim(),
email: email.slice(0, -1),
};
});

debug('Invoking @it/content:git:commit hook', {
trackedFiles: Array.from(fileInfos.keys()),
});
await hooks.run('@it/content:git:commit', (logger) => [
{
logger,
commitInfo: parsingState,
drop: () => {
skipping = true;
},
},
]);

continue;
}

// Skip all entries for a skipped commit
if (skipping) continue;

// TODO: Track git time across renames and moves

// - Added files take the format `A\t<file>`
Expand All @@ -116,16 +144,16 @@ export async function collectGitInfoForContentFiles(): Promise<[string, RawGitTr

if (fileInfo === undefined) {
fileInfos.set(fileName, {
earliest: parsingState.date,
latest: parsingState.date,
earliest: parsingState.secondsSinceEpoch,
latest: parsingState.secondsSinceEpoch,
authors: [parsingState.author],
coAuthors: [...parsingState.coAuthors],
});
continue;
}

fileInfo.earliest = Math.min(fileInfo.earliest, parsingState.date);
fileInfo.latest = Math.max(fileInfo.latest, parsingState.date);
fileInfo.earliest = Math.min(fileInfo.earliest, parsingState.secondsSinceEpoch);
fileInfo.latest = Math.max(fileInfo.latest, parsingState.secondsSinceEpoch);
}

debug('Invoking @it/content:git:listed hook', {
Expand Down Expand Up @@ -154,8 +182,8 @@ export async function collectGitInfoForContentFiles(): Promise<[string, RawGitTr
const name = contentFilePath.replace(sep, ':');

const fileInfo: GitTrackingInfo = {
earliest: new Date(rawFileInfo.earliest),
latest: new Date(rawFileInfo.latest),
earliest: new Date(rawFileInfo.earliest * 1000),
latest: new Date(rawFileInfo.latest * 1000),
authors: rawFileInfo.authors,
coAuthors: rawFileInfo.coAuthors,
};
Expand Down
1 change: 0 additions & 1 deletion packages/content-utils/src/runtime/liveGit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { getEntry } from 'astro:content';
import { join } from 'node:path';
import { collectGitInfoForContentFiles } from './git.js';
import type { GitTrackingInfo } from '@it-astro:content/git';

Expand Down
33 changes: 33 additions & 0 deletions packages/content-utils/virtual.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,34 @@ declare module '@it-astro:content/git' {
coAuthors: GitAuthor[];
};

export type GitAuthor = {
name: string;
email: string;
};

export type GitCommitInfo = {
/**
* Full commit hash.
*/
hash: string;
/**
* Short commit hash.
*/
shortHash: string;
/**
* Commit's "committer date" in seconds since UNIX epoch.
*/
secondsSinceEpoch: number;
/**
* Commit author.
*/
author: GitAuthor;
/**
* Commit co-authors, extracted from the "Co-Authored-By" commit trailers.
*/
coAuthors: GitAuthor[];
};

/**
* Retrieve the latest commit that changed a Content Collection Entry.
*
Expand All @@ -55,6 +83,11 @@ declare module '@it-astro:content/git' {

declare namespace Astro {
export interface IntegrationHooks {
'@it/content:git:commit'?: (params: {
logger: import('astro').AstroIntegrationLogger;
commitInfo: import('@it-astro:content/git').GitCommitInfo;
drop: () => void;
}) => Promise<void> | void;
'@it/content:git:resolved'?: (params: {
logger: import('astro').AstroIntegrationLogger;
file: string;
Expand Down
Loading