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
8 changes: 5 additions & 3 deletions .github/workflows/test-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ jobs:
- name: Install Dependencies
run: |
npm install --no-progress
- name: Run tests
- name: Check build is up to date
run: |
npm config set script-shell bash
npm run test:ci
npm run build
git diff --exit-code || (echo "::error::Build artifacts not committed. Run 'npm run build' and commit the changes." && exit 1)
- name: Run tests
run: npm test
release:
name: Release
permissions:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
package-lock.json
*.tsbuildinfo
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

**Turn a `git log` into a stream of commit objects**

[![npm](https://nodei.co/npm/commit-stream.png?downloads=true&downloadRank=true)](https://nodei.co/npm/commit-stream/)
[![npm](https://nodei.co/npm-dl/commit-stream.png?months=6&height=3)](https://nodei.co/npm/commit-stream/)
[![NPM](https://nodei.co/npm/commit-stream.svg?style=flat&data=n,v&color=blue)](https://nodei.co/npm/commit-stream/)

## API

**`commitStream([defaultGithubUser, defaultGithubProject])`**

Returns an object stream that you can pipe a stream of newlines from the output of `git log` to. The output of the stream is a series of objects, each representing a commit from the log.`
Returns an object stream that you can pipe a stream of newlines from the output of `git log` to. The output of the stream is a series of objects, each representing a commit from the log.

Intended to be used on a standard log format (not a simplified one using one of the simplifying `--format` options) but it will also process change stats if you `git log --stat`.

Expand All @@ -18,18 +17,20 @@ Typical usage:
```js
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
import { Readable } from 'node:stream'
import listStream from 'list-stream'

const { stdout } = spawn('bash', ['-c', 'git log'])

Readable.from(createInterface({ input: stdout, crlfDelay: Infinity })) // break up by newline characters
.pipe(commitStream('rvagg', 'commit-stream'))
.pipe(listStream.obj(onCommitList))

function onCommitList (err, list) {
// list is an array of objects
}
import { pipeline } from 'node:stream/promises'
import commitStream from 'commit-stream'

const { stdout } = spawn('git', ['log'])
const commits = []

await pipeline(
createInterface({ input: stdout, crlfDelay: Infinity }), // break up by newline characters
commitStream('rvagg', 'commit-stream'),
async (source) => {
for await (const commit of source) commits.push(commit)
}
)
// commits is an array of objects
```

Commit object properties:
Expand Down
145 changes: 85 additions & 60 deletions commit-stream.js
Original file line number Diff line number Diff line change
@@ -1,84 +1,120 @@
import { Transform } from 'node:stream'
import { stripVTControlCharacters } from 'node:util'

/**
* @typedef {Object} Person
* @property {string} name
* @property {string} email
*/

/**
* @typedef {Object} Changes
* @property {number} files
* @property {number} insertions
* @property {number} deletions
*/

/**
* @typedef {Object} Commit
* @property {string} sha
* @property {Person} [author]
* @property {Person[]} [authors]
* @property {string} [authorDate]
* @property {string} [commitDate]
* @property {string} [summary]
* @property {string[]} [description]
* @property {Person[]} [reviewers]
* @property {Changes} [changes]
* @property {string} [prUrl]
* @property {string} [ghUser]
* @property {string} [ghProject]
* @property {number} [ghIssue]
* @property {string} [cveId]
*/

/**
* Create an object stream that turns lines of `git log` output into commit objects.
*
* @param {string} [ghUser] - GitHub user used to expand short `#XX` PR references
* @param {string} [ghProject] - GitHub project used to expand short `#XX` PR references
* @returns {Transform}
*/
export default function commitStream (ghUser, ghProject) {
/** @type {Commit | undefined} */
let commit

return new Transform({ objectMode: true, transform: onLine, flush: onEnd })
return new Transform({
objectMode: true,
transform (line, _, callback) {
emit(this, addLine(line))
callback()
},
flush (callback) {
emit(this, commit)
callback()
}
})

/**
* @param {string} line
* @returns {Commit | undefined} the previous commit, when `line` starts a new one
*/
function addLine (line) {
line = stripVTControlCharacters(line)

if (line.length === 0) return

let old, m

if (/^commit \w+$/.test(line)) {
old = commit
const old = commit
commit = { sha: line.split(' ')[1] }
} else if ((m = line.match(/^CommitDate:\s+(.*)$/)) !== null) {
if (commit === undefined || commit === null) {
throw new Error('Something unexpected occurred')
}
return old
}

let m

if (!commit) {
throw new Error('Something unexpected occurred')
}

if ((m = line.match(/^CommitDate:\s+(.*)$/)) !== null) {
commit.commitDate = m[1].trim()
} else if (
(m = line.match(
/^\s*(?:Author|Co[- ]?authored[- ]?by):?\s*([^<]+) <([^>]+)>\s*$/i
)) !== null
) {
if (commit === undefined || commit === null) {
throw new Error('Something unexpected occurred')
}
if (commit.authors === undefined || commit.authors === null) {
commit.authors = []
}
commit.authors.push({ name: m[1], email: m[2] })
(commit.authors ??= []).push({ name: m[1], email: m[2] })
} else if ((m = line.match(/^(?:AuthorDate|Date):\s+(.*)$/)) !== null) {
if (commit === undefined || commit === null) {
throw new Error('Something unexpected occurred')
}
commit.authorDate = m[1].trim()
} else if (
(m = line.match(/^\s+Reviewed[- ]?By:?\s*([^<]+) <([^>]+)>\s*$/i)) !==
null
) {
if (commit.reviewers === undefined || commit.reviewers === null) {
commit.reviewers = []
}
commit.reviewers.push({ name: m[1], email: m[2] })
(commit.reviewers ??= []).push({ name: m[1], email: m[2] })
} else if ((m = line.match(/\bCVE-ID:\s+(CVE-\d{4}-\d{4,})\b/)) !== null) {
commit.cveId = m[1]
} else if ((m = line.match(/^\s+PR(?:[- ]?URL)?:?\s*(.+)\s*$/) || line.match(/\(#(\d+)\)$/)) !== null) {
commit.prUrl = m[1]
if (
typeof ghUser === 'string' &&
ghUser.length !== 0 &&
typeof ghProject === 'string' &&
ghProject.length !== 0 &&
(m = commit.prUrl.match(/^\s*#?(\d+)\s*$/)) !== null
) {
if (ghUser && ghProject && (m = commit.prUrl.match(/^\s*#?(\d+)\s*$/)) !== null) {
commit.prUrl = `https://github.com/${ghUser}/${ghProject}/pull/${m[1]}`
}
if (
(m = commit.prUrl.match(
/^(https?:\/\/.+\/([^/]+)\/([^/]+))\/\w+\/(\d+)(\/)?$/i
)) !== null
) {
commit.ghIssue = parseInt(m[4])
commit.ghIssue = Number(m[4])
commit.ghUser = m[2]
commit.ghProject = m[3]
}
if ((m = line.match(/^ {4}(.*)\s\(#\d+\)$/)) && !commit.summary) {
commit.summary = m[1]
}
} else if (/^ {4}/.test(line) && (line = line.trim()).length !== 0) {
if (commit.summary === undefined || commit.summary === null) {
if (commit.summary === undefined) {
commit.summary = line
} else {
if (commit.description === undefined || commit.description === null) {
commit.description = []
}
commit.description.push(line)
(commit.description ??= []).push(line)
}
} else if (
/^ [^ ]/.test(line) &&
Expand All @@ -88,34 +124,23 @@ export default function commitStream (ghUser, ghProject) {
)) !== null
) {
commit.changes = {
files: parseInt(m[1]),
insertions: parseInt(m[2] ?? 0),
deletions: parseInt(m[3] ?? 0)
files: Number(m[1]),
insertions: Number(m[2] ?? 0),
deletions: Number(m[3] ?? 0)
}
}

return old
}

function onLine (line, _, callback) {
const commit = addLine(line)

if (commit?.authors?.length > 0) {
commit.author = commit.authors[0]

this.push(commit)
}

callback()
}
}

function onEnd (callback) {
if (commit?.authors?.length > 0) {
commit.author = commit.authors[0]

this.push(commit)
}

callback()
/**
* Push a completed commit, skipping any without an author.
*
* @param {Transform} stream
* @param {Commit | undefined} commit
*/
function emit (stream, commit) {
if (commit?.authors?.length) {
commit.author = commit.authors[0]
stream.push(commit)
}
}
24 changes: 18 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@
"version": "2.2.6",
"description": "Turn a `git log` into a stream of commit objects",
"main": "commit-stream.js",
"types": "types/commit-stream.d.ts",
"exports": {
".": {
"types": "./types/commit-stream.d.ts",
"default": "./commit-stream.js"
}
},
"type": "module",
"scripts": {
"build": "true",
"test:ci": "npm run test",
"test": "node --test test.js"
"lint": "standard",
"build": "npm run build:types",
"build:types": "tsc --build",
"prepublishOnly": "npm run build",
"test:unit": "node --test test.js",
"test": "npm run lint && npm run test:unit"
},
"repository": {
"type": "git",
Expand All @@ -21,10 +31,12 @@
"homepage": "https://github.com/nodejs/commit-stream",
"devDependencies": {
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/git": "^11.0.0",
"@semantic-release/git": "^11.0.1",
"@types/node": "^24.19.2",
"conventional-changelog-conventionalcommits": "^9.3.1",
"list-stream": "^2.1.0",
"semantic-release": "^25.0.3"
"semantic-release": "^25.0.9",
"standard": "^17.1.2",
"typescript": "^7.0.2"
},
"release": {
"branches": [
Expand Down
Loading
Loading