Skip to content
Merged
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
7 changes: 4 additions & 3 deletions cli/src/__tests__/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('fs')

import fs from 'fs'
import { ChangelogPlugin } from '../plugins/changelog'
import fs from 'node:fs'
import type { PluginContext } from '../plugins'
import { ChangelogPlugin } from '../plugins/changelog'

const plugin = new ChangelogPlugin()

Expand Down Expand Up @@ -164,6 +164,7 @@ function makeCtx(commits: string[]): PluginContext {
tag: 'v1.4.0',
commitCount: commits.length,
dryRun: false,
published: false,
},
options: {},
commits,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/__tests__/channels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
// Must be declared before importing the module under test so vitest can hoist it
vi.mock('child_process')

import { execSync } from 'child_process'
import { execSync } from 'node:child_process'
import { applyChannel, detectChannel } from '../channels'

const mockExecSync = vi.mocked(execSync)
Expand Down
38 changes: 20 additions & 18 deletions cli/src/__tests__/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect, vi } from 'vitest'
import { execSync } from 'node:child_process'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plugin } from '../plugins'
import { createRelease } from '../release'
import * as tags from '../tags'
import type { Plugin } from '../plugins'
import { execSync } from 'child_process'

vi.mock('../tags', () => ({
resolveLatestTag: vi.fn(),
Expand All @@ -13,16 +13,25 @@ vi.mock('child_process', () => ({
execSync: vi.fn(),
}))

const mockExecSync = vi.mocked(execSync)

function setupMocks() {
;(tags.resolveLatestTag as ReturnType<typeof vi.fn>).mockReturnValue({
latest: '1.0.0',
tag: 'v1.0.0',
isInitial: false,
})
;(tags.getCommitsSinceTag as ReturnType<typeof vi.fn>).mockReturnValue(['feat: new feature'])
mockExecSync.mockReturnValue('main' as any) // getCurrentBranch
}

describe('Plugin System', () => {
it('executes onResolved and onSuccess hooks', async () => {
;(tags.resolveLatestTag as any).mockReturnValue({
latest: '1.0.0',
tag: 'v1.0.0',
isInitial: false,
})
;(tags.getCommitsSinceTag as any).mockReturnValue(['feat: new feature'])
;(execSync as any).mockReturnValue('main') // for getCurrentBranch
beforeEach(() => {
vi.resetAllMocks()
setupMocks()
})

it('executes onResolved and onSuccess hooks', async () => {
const plugin: Plugin = {
name: 'test-plugin',
onResolved: vi.fn(),
Expand All @@ -36,13 +45,6 @@ describe('Plugin System', () => {
})

it('does not execute onSuccess in dryRun mode', async () => {
;(tags.resolveLatestTag as any).mockReturnValue({
latest: '1.0.0',
tag: 'v1.0.0',
isInitial: false,
})
;(tags.getCommitsSinceTag as any).mockReturnValue(['feat: new feature'])

const plugin: Plugin = {
name: 'test-plugin',
onResolved: vi.fn(),
Expand Down
83 changes: 83 additions & 0 deletions cli/src/__tests__/release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { execSync } from 'node:child_process'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plugin } from '../plugins'
import { createRelease } from '../release'
import * as tags from '../tags'

vi.mock('../tags', () => ({
resolveLatestTag: vi.fn(),
getCommitsSinceTag: vi.fn(),
}))

vi.mock('child_process', () => ({
execSync: vi.fn(),
}))

const mockExecSync = vi.mocked(execSync)

function setupMocks() {
;(tags.resolveLatestTag as ReturnType<typeof vi.fn>).mockReturnValue({
latest: '1.0.0',
tag: 'v1.0.0',
isInitial: false,
})
;(tags.getCommitsSinceTag as ReturnType<typeof vi.fn>).mockReturnValue(['feat: new feature'])
mockExecSync.mockReturnValue('main' as any) // getCurrentBranch
}

describe('Publish', () => {
beforeEach(() => {
vi.resetAllMocks()
setupMocks()
})

it('creates and pushes the tag when publish is true', async () => {
const result = await createRelease({ publish: true })

expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining('git tag v1.1.0'),
expect.any(Object)
)
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining('git push origin v1.1.0'),
expect.any(Object)
)
expect(result.published).toBe(true)
})

it('does not create a tag when publish is false', async () => {
await createRelease({ publish: false })

expect(mockExecSync).not.toHaveBeenCalledWith(
expect.stringContaining('git tag'),
expect.any(Object)
)
})

it('does not publish in dry run mode', async () => {
const result = await createRelease({ publish: true, dryRun: true })

expect(mockExecSync).not.toHaveBeenCalledWith(
expect.stringContaining('git tag'),
expect.any(Object)
)
expect(result.published).toBe(false)
})

it('calls onFailure and rethrows when pushTag fails', async () => {
mockExecSync.mockImplementation((cmd: string) => {
if (cmd.includes('git tag')) throw new Error('git tag failed')
return 'main' as any
})

const plugin: Plugin = {
name: 'test-plugin',
onFailure: vi.fn(),
}

await expect(createRelease({ publish: true, plugins: [plugin] })).rejects.toThrow(
'Failed to create or push tag'
)
expect(plugin.onFailure).toHaveBeenCalled()
})
})
12 changes: 6 additions & 6 deletions cli/src/channels.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { execSync } from 'child_process'
import { execSync } from 'node:child_process'

export type Channel = 'stable' | 'alpha' | 'beta' | 'rc' | 'feature'

/** Map a branch name to its default release channel. */
export function detectChannel(branch: string): Channel {
if (branch === 'main' || branch === 'master') return 'stable'
if (branch === 'develop') return 'alpha'
if (/^release\//.test(branch)) return 'rc'
if (branch.startsWith('release')) return 'rc'
if (/^feat(ure)?\//.test(branch)) return 'feature'
return 'alpha'
}
Expand Down Expand Up @@ -39,9 +39,9 @@ export function applyChannel(
if (channel === 'feature') {
const slug = branch
.replace(/(^feature|^feat)\//, '')
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.replaceAll(/[^a-zA-Z0-9]/g, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^-|-$/g, '')
.toLowerCase()
return `${version}-${slug}`
}
Expand All @@ -65,7 +65,7 @@ function getNextPreReleaseIndex(baseVersion: string, channel: string, prefix: st
.filter(Boolean)
.map((tag) => {
const match = tag.match(/\.(\d+)$/)
return match ? parseInt(match[1], 10) : -1
return match ? Number.parseInt(match[1], 10) : -1
})
.filter((n) => n >= 0)

Expand Down
13 changes: 11 additions & 2 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
#!/usr/bin/env node
import { Command } from 'commander'
import { createRelease } from './release'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Channel } from './channels'
import { loadConfig, resolvePlugins } from './config'
import { createRelease } from './release'

const { version } = JSON.parse(readFileSync(resolve(__dirname, './package.json'), 'utf-8')) as {
version: string
}

const program = new Command()

program
.name('gitcraft')
.description('Craft production-ready releases from your Git history.')
.version('0.1.0')
.version(version)

program
.command('release')
.description('Analyze commits and publish the next semantic version release')
.option('-c, --channel <channel>', 'Release channel: stable, alpha, beta, rc, feature')
.option('-p, --tag-prefix <prefix>', 'Git tag prefix', 'v')
.option('-n, --dry-run', 'Preview the release without running plugins or writing files')
.option('--publish', 'Create and push the git tag to the remote')
.option('--path <path>', 'Only analyze commits touching this path (monorepo support)')
.action(async (opts) => {
try {
Expand All @@ -27,6 +34,7 @@ program
channel: opts.channel as Channel | undefined,
tagPrefix: opts.tagPrefix,
dryRun: opts.dryRun ?? false,
publish: opts.publish ?? false,
plugins,
filterPath: opts.path,
})
Expand All @@ -36,6 +44,7 @@ program
console.log(` ${result.previousVersion} → ${result.nextVersion} (${result.bumpType})`)
console.log(` Tag: ${result.tag}`)
console.log(` Commits: ${result.commitCount}`)
if (result.published) console.log(` Published: ${result.tag} pushed to origin`)

if (result.dryRun) {
console.log('\nDry run — no plugins ran, no files were written.')
Expand Down
97 changes: 68 additions & 29 deletions cli/src/release.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { execSync } from 'node:child_process'
import { determineBump } from './analyzer'
import type { Channel } from './channels'
import { applyChannel, detectChannel } from './channels'
Expand All @@ -10,6 +10,7 @@ export interface ReleaseOptions {
channel?: Channel
tagPrefix?: string
dryRun?: boolean
publish?: boolean
plugins?: Plugin[]
filterPath?: string
}
Expand All @@ -21,6 +22,7 @@ export interface ReleaseResult {
tag: string
commitCount: number
dryRun: boolean
published: boolean
/** Populated by the changelog plugin — markdown for this release entry. */
releaseNotes?: string
}
Expand All @@ -29,7 +31,6 @@ export async function createRelease(opts: ReleaseOptions = {}): Promise<ReleaseR
const prefix = opts.tagPrefix ?? 'v'
const branch = getCurrentBranch()
const channel = opts.channel ?? detectChannel(branch)

const { latest, tag: latestTag, isInitial } = resolveLatestTag(prefix)
const commits = getCommitsSinceTag(latestTag, isInitial, opts.filterPath)

Expand All @@ -49,35 +50,10 @@ export async function createRelease(opts: ReleaseOptions = {}): Promise<ReleaseR
tag: nextTag,
commitCount: commits.length,
dryRun: opts.dryRun ?? false,
published: false,
}

const context: PluginContext = { result, options: opts, commits }

// Run 'onResolved' hook
if (opts.plugins) {
for (const plugin of opts.plugins) {
if (plugin.onResolved) await plugin.onResolved(context)
}
}

if (!opts.dryRun) {
try {
if (opts.plugins) {
for (const plugin of opts.plugins) {
if (plugin.onSuccess) await plugin.onSuccess(context)
}
}
} catch (err) {
if (opts.plugins) {
for (const plugin of opts.plugins) {
if (plugin.onFailure) await plugin.onFailure(err as Error)
}
}
throw err
}
}

return result
return craft(result, opts, commits)
}

function getCurrentBranch(): string {
Expand All @@ -90,3 +66,66 @@ function getCurrentBranch(): string {
throw new Error('Could not determine current Git branch.')
}
}

function pushTag(tag: string): void {
try {
execSync(`git tag ${tag}`, { stdio: 'inherit' })
execSync(`git push origin ${tag}`, { stdio: 'inherit' })
} catch {
throw new Error(`Failed to create or push tag: ${tag}`)
}
}

async function runPluginResolved(
plugins: Plugin[] | undefined,
context: PluginContext
): Promise<void> {
if (!plugins) return
for (const plugin of plugins) {
if (plugin.onResolved) await plugin.onResolved(context)
}
}

async function runPluginSuccess(
plugins: Plugin[] | undefined,
context: PluginContext
): Promise<void> {
if (!plugins) return
for (const plugin of plugins) {
if (plugin.onSuccess) await plugin.onSuccess(context)
}
}

async function runPluginFailure(plugins: Plugin[] | undefined, error: Error): Promise<void> {
if (!plugins) return
for (const plugin of plugins) {
if (plugin.onFailure) await plugin.onFailure(error)
}
}

async function craft(
result: ReleaseResult,
options: ReleaseOptions,
commits: string[]
): Promise<ReleaseResult> {
const context: PluginContext = { result, options, commits }

await runPluginResolved(options.plugins, context)

if (!options.dryRun) {
try {
if (options.publish) {
pushTag(result.tag)
result.published = true
}

await runPluginSuccess(options.plugins, context)
} catch (err) {
await runPluginFailure(options.plugins, err as Error)

throw err
}
}

return result
}
Loading