Skip to content
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
10 changes: 10 additions & 0 deletions src/Frog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ describe('create', () => {
expect(await frog.logs()).toHaveLength(1)
})

test('behavior: does not deduplicate empty or fence-only titles', async () => {
const frog = Frog.create({ store: Store.file({ root: await tmpdir() }) })
const first = await frog.log({ ...entry, title: '---' })
const second = await frog.log({ ...entry, title: '---' })

expect(second.created).toBe(true)
expect(second.entry.id).not.toBe(first.entry.id)
expect(await frog.logs()).toHaveLength(2)
})

test('behavior: delegates atomic logging to a store that provides it', async () => {
const log = vi.fn(async () => ({
created: false,
Expand Down
5 changes: 4 additions & 1 deletion src/Frog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ async function log(
): Promise<Store.LogResult> {
if (store.log) return store.log(entry, options)

if (!options.force) {
// An empty normalized title (empty or punctuation-only, such as `---`) never deduplicates: every
// such entry would otherwise match every other, including the mangled piped entries this guard
// exists to contain.
if (!options.force && Entry.normalizeTitle(entry.title) !== '') {
const duplicate = (await store.read()).find(
(candidate) => Entry.normalizeTitle(candidate.title) === Entry.normalizeTitle(entry.title),
)
Expand Down
12 changes: 10 additions & 2 deletions src/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,10 @@ export function postgres(options: postgres.Options): Store {
async write(entry, writeOptions = {}) {
const id = writeOptions.id ?? newPostgresId(entry.title)
const dedupeKey = `entry:${id}`
const titleKey = `title:${Entry.normalizeTitle(entry.title)}`
// An empty normalized title never takes the shared title key: every punctuation-only
// title would otherwise share `title:` and collide.
const normalized = Entry.normalizeTitle(entry.title)
const titleKey = normalized === '' ? dedupeKey : `title:${normalized}`
await sql`
INSERT INTO ${table} AS existing(namespace, id, dedupe_key, contents)
VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)})
Expand Down Expand Up @@ -499,9 +502,14 @@ export function postgres(options: postgres.Options): Store {
},
async log(entry, logOptions = {}) {
const id = newPostgresId(entry.title)
const normalized = Entry.normalizeTitle(entry.title)
// An empty normalized title never deduplicates: every punctuation-only title would
// otherwise share one key and count as the same friction.
const dedupeKey = logOptions.force
? `forced:${id}`
: `title:${Entry.normalizeTitle(entry.title)}`
: normalized === ''
? `entry:${id}`
: `title:${normalized}`
const result = await sql<PostgresRow[]>`
INSERT INTO ${table}(namespace, id, dedupe_key, contents)
VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)})
Expand Down
18 changes: 12 additions & 6 deletions src/cli/commands/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,14 @@ export const log = Cli.create('log', {
if (entries && !entries.ok) return c.error({ code: entries.code, message: entries.message })

// Stores without atomic logging still catch the repeat at authoring time, where it is cheap.
const duplicate = entries?.ok
? entries.value.find(
(entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title),
)
: undefined
// An empty normalized title never matches: punctuation-only titles such as `---` all
// normalize to the empty string and must not collide with each other.
const duplicate =
entries?.ok && Entry.normalizeTitle(title) !== ''
? entries.value.find(
(entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title),
)
: undefined
if (duplicate && !c.options.force)
return c.error({
code: 'DUPLICATE_FRICTION',
Expand All @@ -264,7 +267,10 @@ export const log = Cli.create('log', {
if (promptedSeverity && !promptedSeverity.ok)
return c.error({ code: promptedSeverity.code, message: promptedSeverity.message })

const severity = c.options.severity ?? (promptedSeverity?.ok ? promptedSeverity.value : 'minor')
const severity =
c.options.severity ??
input?.severity ??
(promptedSeverity?.ok ? promptedSeverity.value : 'minor')

const logged = await attempt(
Frog.create({ store }).log(
Expand Down
36 changes: 36 additions & 0 deletions src/cli/internal/stdin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,42 @@ describe('parse', () => {
test.for(['', ' ', '\n\n\n'])('behavior: %j has no title', (contents) => {
expect(stdin.parse(contents)).toBeUndefined()
})

test('behavior: parses a frontmatter block for title, severity, and body', () => {
const contents = [
'---',
"title: 'Filters ignored'",
'severity: major',
'---',
'',
'## Description',
'',
'The filter was swallowed.',
'',
].join('\n')
expect(stdin.parse(contents)).toEqual({
body: '## Description\n\nThe filter was swallowed.',
severity: 'major',
title: 'Filters ignored',
})
})

test('behavior: a frontmatter block without severity defaults to minor', () => {
const contents = ["---", "title: 'Filters ignored'", '---', '', 'Body.'].join('\n')
expect(stdin.parse(contents)).toEqual({
body: 'Body.',
severity: 'minor',
title: 'Filters ignored',
})
})

test.for([
'---\ntitle:\n---\n\nBody.',
'---\nseverity: minor\n---\n\nBody.',
'---\ntitle: one\n\nBody without a closing fence.',
])('behavior: malformed frontmatter %j has no title', (contents) => {
expect(stdin.parse(contents)).toBeUndefined()
})
})

describe('read', () => {
Expand Down
43 changes: 40 additions & 3 deletions src/cli/internal/stdin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs'
import type { Readable } from 'node:stream'
import * as Entry from '../../Entry.js'

/** How long to wait for the first byte when it is unclear whether anything is coming. */
export const grace = 100
Expand Down Expand Up @@ -103,20 +104,28 @@ function arriving(stream: Readable, deadline: number): Promise<boolean> {
}

/**
* Splits piped input into a title and a body.
* Splits piped input into a title, a body, and an optional severity.
*
* Shaped like a commit message: the first line is the title, the rest is the body. A caller never has to
* quote a multi-line string on a command line, where an apostrophe in the body would end the argument.
*
* A complete entry write-up is recognized by its opening fence: when the first non-empty line is `---`,
* the title and severity come from the YAML frontmatter block and the body is the Markdown after the
* closing fence. Malformed frontmatter yields nothing rather than a `---` title that would poison
* later duplicate detection.
*
* @param contents - Piped input.
* @returns The title and body, or `undefined` when there is no title to take.
* @returns The title, body, and frontmatter severity when present, or `undefined` when there is no
* title to take.
*/
export function parse(contents: string): { body: string; title: string } | undefined {
export function parse(contents: string): parse.Result | undefined {
const lines = contents.replace(/\r\n/g, '\n').split('\n')

const start = lines.findIndex((line) => line.trim())
if (start === -1) return undefined

if (lines[start]?.trim() === '---') return parseFrontmatter(lines.slice(start))

const title = lines[start]?.trim()
if (!title) return undefined

Expand All @@ -128,3 +137,31 @@ export function parse(contents: string): { body: string; title: string } | undef
title,
}
}

export declare namespace parse {
/** Piped entry: commit-message title and body, plus frontmatter severity when piped as a file. */
type Result = {
/** Markdown body: everything after the title, or after the frontmatter block. */
body: string
/** Frontmatter severity. Absent for the commit-message shape; the caller defaults it. */
severity?: Entry.Severity | undefined
/** One line, specific enough to search for. */
title: string
}
}

/**
* Parses piped input that starts with a YAML frontmatter fence.
*
* @param lines - Piped lines starting at the opening `---`.
* @returns The frontmatter title, severity, and Markdown body, or `undefined` when the block is
* malformed or names no title.
*/
function parseFrontmatter(lines: readonly string[]): parse.Result | undefined {
try {
const parsed = Entry.parse(lines.join('\n'), { id: 'piped' })
return { body: parsed.body, severity: parsed.severity, title: parsed.title }
} catch {
return undefined
}
}