publish(task 13): git-diff change-detection für post-ordner

filterPostDirs(lines, contentRoot) extrahiert post-verzeichnisse aus
git-diff-ausgabe (index.md-matches + asset-changes), ignoriert
_drafts/. contentRoot ist parameter (blaupausen-tauglich für nicht-
hugo-layouts). changedPostDirs(from, to, contentRoot, runner?) ruft
"git diff --name-only A..B" via dependency-injected runner. Plus
allPostDirs() für den --force-all-modus. 4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-18 05:33:56 +02:00
parent 02a955c46f
commit b6196f1052
2 changed files with 122 additions and 0 deletions

View File

@ -0,0 +1,67 @@
export type GitRunner = (args: string[]) => Promise<string>
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function filterPostDirs(lines: string[], contentRoot: string): string[] {
const root = contentRoot.replace(/\/$/, '')
const prefix = root + '/'
const indexRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/index\\.md$`)
const assetRe = new RegExp(`^${escapeRegex(prefix)}([^/]+)/`)
const drafts = prefix + '_'
const dirs = new Set<string>()
for (const line of lines) {
const l = line.trim()
if (!l) continue
if (l.startsWith(drafts)) continue
const indexMatch = l.match(indexRe)
if (indexMatch) {
dirs.add(`${prefix}${indexMatch[1]}`)
continue
}
const assetMatch = l.match(assetRe)
if (assetMatch && !l.endsWith('.md')) {
dirs.add(`${prefix}${assetMatch[1]}`)
}
}
return [...dirs].sort()
}
const defaultRunner: GitRunner = async (args) => {
const proc = new Deno.Command('git', { args, stdout: 'piped', stderr: 'piped' })
const out = await proc.output()
if (out.code !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${new TextDecoder().decode(out.stderr)}`)
}
return new TextDecoder().decode(out.stdout)
}
export interface DiffArgs {
from: string
to: string
contentRoot: string
runner?: GitRunner
}
export async function changedPostDirs(args: DiffArgs): Promise<string[]> {
const runner = args.runner ?? defaultRunner
const stdout = await runner(['diff', '--name-only', `${args.from}..${args.to}`])
return filterPostDirs(stdout.split('\n'), args.contentRoot)
}
export async function allPostDirs(contentRoot: string): Promise<string[]> {
const result: string[] = []
for await (const entry of Deno.readDir(contentRoot)) {
if (entry.isDirectory && !entry.name.startsWith('_')) {
const indexPath = `${contentRoot}/${entry.name}/index.md`
try {
const stat = await Deno.stat(indexPath)
if (stat.isFile) result.push(`${contentRoot}/${entry.name}`)
} catch {
// skip folders without index.md
}
}
}
return result.sort()
}

View File

@ -0,0 +1,55 @@
import { assertEquals } from '@std/assert'
import {
changedPostDirs,
filterPostDirs,
type GitRunner,
} from '../src/core/change-detection.ts'
Deno.test('filterPostDirs: extrahiert post-ordner aus dateipfaden (content/posts)', () => {
const lines = [
'content/posts/a/index.md',
'content/posts/b/image.png',
'content/posts/c/other.md',
'README.md',
'app/src/lib/x.ts',
]
assertEquals(
filterPostDirs(lines, 'content/posts').sort(),
['content/posts/a', 'content/posts/b'],
)
})
Deno.test('filterPostDirs: respektiert alternativen root (blog/)', () => {
const lines = [
'blog/x/index.md',
'blog/y/pic.png',
'content/posts/z/index.md',
'README.md',
]
assertEquals(filterPostDirs(lines, 'blog').sort(), ['blog/x', 'blog/y'])
})
Deno.test('filterPostDirs: ignoriert _drafts und non-index.md', () => {
const lines = [
'content/posts/a/index.md',
'content/posts/a/extra.md',
'content/posts/_drafts/x/index.md',
]
assertEquals(filterPostDirs(lines, 'content/posts'), ['content/posts/a'])
})
Deno.test('changedPostDirs: nutzt git diff --name-only A..B', async () => {
const runner: GitRunner = (args) => {
assertEquals(args[0], 'diff')
assertEquals(args[1], '--name-only')
assertEquals(args[2], 'HEAD~1..HEAD')
return Promise.resolve('content/posts/x/index.md\nREADME.md\n')
}
const dirs = await changedPostDirs({
from: 'HEAD~1',
to: 'HEAD',
contentRoot: 'content/posts',
runner,
})
assertEquals(dirs, ['content/posts/x'])
})