remark-common-changelog
Version:
Lint or fix a changelog written in markdown, following Common Changelog.
228 lines (183 loc) • 6.79 kB
JavaScript
import git from 'isomorphic-git'
import Githost from 'find-githost'
import fs from 'fs'
import path from 'path'
import cp from 'child_process'
import versionCommitRe from './version-commit-re.js'
// Get a range of git commits and annotate commits from merges
// TODO: publish as git-log-between
export default function (opts, callback) {
if (typeof opts === 'function') {
callback = opts
opts = null
}
const promise = getCommits(opts)
if (callback == null) return promise
promise.then(function (res) {
process.nextTick(callback, null, res)
}).catch(function (err) {
process.nextTick(callback, err)
})
}
async function getCommits (opts) {
if (!opts) opts = {}
const dir = path.resolve(opts.cwd || '.')
const gitdir = opts.gitdir ? path.resolve(opts.gitdir) : path.join(dir, '.git')
const lowerInclusive = !('gt' in opts)
const upperInclusive = !('lt' in opts)
const lowerish = looseHead(lowerInclusive ? opts.gte : opts.gt) || null
const upperish = looseHead(upperInclusive ? opts.lte : opts.lt) || 'HEAD'
const limit = opts.limit || 1e3
const lowerCommitOid = lowerish ? await resolveCommitish(dir, gitdir, lowerish) : null
const upperCommitOid = await resolveCommitish(dir, gitdir, upperish)
const ignoreTags = opts.ignoreTags === true
const commits = []
const mergeCommits = []
const children = new Map()
let complete = false
let position = upperCommitOid
let lastCommitOid = lowerCommitOid
while (!complete && commits.length < limit) {
// Walk backwards through history, starting at *position*
const depth = Math.min(limit - commits.length + 1, 10)
const chunk = await git.log({ fs, dir, gitdir, depth, ref: position })
// Ignore upper bound or our previous position
if (!upperInclusive || commits.length) chunk.splice(0, 1)
if (!chunk.length) break
for (const { oid, commit } of chunk) {
// Workaround: stop if a tag is encountered, in cases where `gt` is a tag
// on a commit on another branch, which we therefore would never see here.
// Resolving all tags to an oid can be slow, so instead match on message.
if (versionCommitRe.test(commit.message) && !ignoreTags) {
lastCommitOid = oid
complete = true
break
}
if (lowerCommitOid !== null && oid === lowerCommitOid) {
complete = true
if (!lowerInclusive) break
}
const lines = commit.message.trim().split('\n')
const title = lines.shift().trim()
const description = lines.join('\n').trim()
commit.title = title
commit.description = description
commit.message = title + (description ? '\n\n' + description : '')
commit.isMergeCommit = false
commit.pr = null
commit.oid = oid
commits.push(commit)
if (commit.parent.length === 1) {
children.set(commit.parent[0], commit)
} else if (commit.parent.length > 1) {
commit.isMergeCommit = true
commit.pr = prNumber(commit.message)
commit.commits = []
// Only support simple merges, not "octopus merges" with > 2 parents
if (commit.parent.length === 2) mergeCommits.push(commit)
}
if (commits.length === limit) complete = true
if (complete) break
}
position = chunk[chunk.length - 1].oid
}
for (const mc of mergeCommits) {
// Skip non-github merges for now
if (mc.pr == null) continue
let [src, dst] = mc.parent
let child
// Find the commit that has *src* as its parent, walk up until *dst*.
// 60% of the time, it works every time.
while (src !== dst && (child = children.get(src)) && mc.commits.length < limit) {
child.mergeCommit = mc
child.pr = mc.pr
mc.commits.push(child)
src = child.oid
}
// Sort latest-first
mc.commits.reverse()
}
// Add repository metadata, to help create PR & commit references
const ghrepo = await tryGetRepo(dir, gitdir)
for (const commit of commits) {
commit.ghrepo = ghrepo
}
if (opts.submodules && fs.existsSync(path.join(dir, '.gitmodules'))) {
const submodules = new Map()
// TODO: we don't always have a lowerCommitOid/lastCommitOid
for (const { commit, submodule } of modules(dir, lastCommitOid)) {
submodules.set(submodule, { gt: commit })
}
for (const { commit, submodule } of modules(dir, upperCommitOid)) {
// Ignore removed submodule
if (!submodules.has(submodule)) continue
// Ignore submodule that didn't change
if (submodules.get(submodule).gt === commit) submodules.delete(submodule)
else submodules.get(submodule).lte = commit
}
for (const [submodule, { gt, lte }] of submodules) {
const cwd = path.join(dir, submodule)
const gitdir = path.join(dir, '.git', 'modules', submodule)
const subcommits = await getCommits({
cwd,
gitdir,
gt,
lte,
limit,
ignoreTags: true,
submodules: false
})
commits.push(...subcommits.map(c => ({ ...c, submodule })))
}
}
return commits
}
function modules (dir, ref) {
if (!ref) {
throw new Error('Cannot list submodules without ref')
}
// TODO: does isomorphic-git have an API for this?
return cp.execFileSync('git', ['ls-tree', ref], { cwd: dir, encoding: 'utf8' })
.split(/\r?\n/)
.map(line => line.split(/\s+/))
.filter(columns => columns[1] === 'commit')
.map(([_, type, commit, submodule]) => ({ commit, submodule }))
}
async function tryGetRepo (dir, gitdir) {
// TODO: remove try/catch
try {
let host = Githost.fromPkg(dir, { optional: true })
if (host) return host.slug({ commitish: false })
const remotes = await git.listRemotes({ fs, dir, gitdir })
const origin = remotes.filter(r => r.remote === 'origin')[0]
if (origin && origin.url) {
host = Githost.fromUrl(origin.url, { optional: true })
if (host) return host.slug({ commitish: false })
}
} catch (err) {
console.error(err)
}
}
async function resolveCommitish (dir, gitdir, ref) {
const oid = await git.resolveRef({ fs, dir, gitdir, ref })
const obj = await git.readObject({ fs, dir, gitdir, oid, encoding: 'utf8' })
// Head or lightweight tag
if (obj.type === 'commit') {
return oid
}
// Annotated tag (has its own git object)
if (obj.type === 'tag' && obj.object.type === 'commit' && obj.object.object) {
return obj.object.object
}
throw new Error('Could not resolve ' + ref + ' to commit')
}
function prNumber (message) {
if (/^Merge pull request #\d/i.test(message)) {
return parseInt(message.slice(20), 10)
} else {
return null
}
}
function looseHead (commitish) {
return /^head$/i.test(commitish) ? 'HEAD' : commitish
}