@jakehamilton/titan
Version:
A little tool for big (monorepo) projects.
407 lines (337 loc) • 9.66 kB
JavaScript
const { execSync } = require("child_process");
const semver = require("semver");
const npm = require("./npm");
const path = require("./path");
const logger = require("./log");
const init = (root) => {
execSync("git init", {
cwd: root,
stdio: "pipe",
});
};
const diff = (options = []) => {
const result = execSync(`git diff ${options.join(" ")}`, {
cwd: npm.getProjectRoot(),
encoding: "utf8",
stdio: "pipe",
});
return result.split("\n");
};
const add = (files = [], options = [], root = npm.getProjectRoot()) => {
execSync(`git add ${files.join(" ")} ${options.join(" ")}`, {
cwd: root,
stdio: "pipe",
});
};
const commit = (
message = "chore: commit",
options = [],
root = npm.getProjectRoot()
) => {
execSync(`git commit -m "${message}" ${options.join(" ")}`, {
cwd: root,
stdio: "pipe",
});
};
const status = (root = npm.getProjectRoot()) => {
const result = execSync(`git status --porcelain`, {
cwd: root,
encoding: "utf8",
stdio: "pipe",
});
const lines = result.split("\n");
const items = lines.reduce((items, line) => {
const match = /^(?<type>M|A|\?\?)\s+(?<file>.+)$/.exec(line.trim());
if (match) {
let type;
switch (match.groups.type) {
default:
type = "unknown";
break;
case "M": {
type = "modified";
break;
}
case "A": {
type = "added";
break;
}
case "??": {
type = "untracked";
break;
}
}
items.push({
type,
file: path.resolve(root, match.groups.file),
});
}
return items;
}, []);
return items;
};
const printChanges = (changes, root = npm.getProjectRoot()) => {
for (const change of changes) {
let type;
switch (change.type) {
default:
case "unknown": {
type = " ?";
break;
}
case "modified": {
type = " M";
break;
}
case "added": {
type = " A";
break;
}
case "untracked": {
type = "??";
break;
}
}
logger.error(`${type}: ${path.relative(root, change.file)}`);
}
};
const log = (options = []) => {
const root = npm.getProjectRoot();
const result = execSync(`git log ${options.join(" ")}`, {
cwd: root,
encoding: "utf8",
stdio: "pipe",
});
return result;
};
const getCommitDataBetween = (from, to) => {
const START_SEPARATOR = `#TITAN_START_COMMIT`;
const END_SEPARATOR = `#TITAN_END_COMMIT`;
const raw = log([
// https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-log.html#_pretty_formats
`--format="format:${START_SEPARATOR}%n%cn%n%ce%n%G?%n%s%n%b%n${END_SEPARATOR}"`,
"--name-only",
`${from}..${to}`,
]);
const lines = raw.trim().split("\n");
const commits = [];
let cur = 0;
while (cur < lines.length) {
if (lines[cur].trim() === START_SEPARATOR) {
cur++;
const author = lines[cur];
cur++;
const email = lines[cur];
cur++;
const signed = lines[cur];
cur++;
const title = lines[cur];
cur++;
const body = [];
while (cur < lines.length && lines[cur].trim() !== END_SEPARATOR) {
body.push(lines[cur]);
cur++;
}
cur++;
const changes = [];
while (
cur < lines.length &&
lines[cur].trim() !== START_SEPARATOR
) {
changes.push(lines[cur]);
cur++;
}
commits.push({
author,
email,
signed,
title,
body: body.join("\n"),
changes,
});
} else {
cur++;
}
}
return commits;
};
const getChangesBetween = (from, to, pkg) => {
const root = npm.getProjectRoot();
const fileDiff = diff(["--name-only", from, to]);
const fileChanges = fileDiff.filter((file) =>
path.resolve(root, file).startsWith(pkg.path)
);
if (fileChanges.length === 0) {
return [];
}
const commits = getCommitDataBetween(from, to);
const affectingCommits = commits.filter((commit) => {
const change = commit.changes.find((file) =>
path.resolve(root, file).startsWith(pkg.path)
);
return Boolean(change);
});
return affectingCommits;
};
const changedSince = (release, target = "HEAD") => {
const changes = getChangesBetween(release.tag.name, target, release.pkg);
return changes.length !== 0;
};
const getUpgradeBetween = (release, target = "HEAD") => {
const commits = getChangesBetween(release.tag.name, target, release.pkg);
if (commits.length === 0) {
return null;
}
let bump = "patch";
for (const commit of commits) {
if (commit.body.match(/^BREAKING CHANGE/g)) {
bump = "major";
break;
}
if (commit.title.startsWith("feat")) {
bump = "minor";
}
}
const newVersion = semver.inc(release.version, bump);
if (newVersion === null) {
logger.error(
`Could not perform bump "${bump}" on version "${release.version}" for package "${release.name}".`
);
process.exit(1);
}
return {
name: release.name,
version: release.version,
newVersion,
pkg: release.pkg,
commits,
bump,
};
};
const getAllUpgradesBetween = (releases, target = "HEAD") => {
const upgrades = [];
for (const release of releases) {
const upgrade = getUpgradeBetween(release, target);
if (upgrade !== null) {
upgrades.push(upgrade);
}
}
return upgrades;
};
const tag = {
at(target = "HEAD") {
const result = execSync(`git tag --points-at ${target}`, {
cwd: npm.getProjectRoot(),
encoding: "utf8",
stdio: "pipe",
});
return result.trim().split("\n").filter(Boolean);
},
list() {
const raw = execSync(`git tag --list -n1 --sort=-taggerdate`, {
cwd: npm.getProjectRoot(),
encoding: "utf8",
stdio: "pipe",
});
const lines = raw.split("\n");
const tags = lines.reduce((tags, line) => {
const match = /(?<tag>\S+)\s*(?<annotation>.+)?/.exec(line);
if (match) {
tags.push({
name: match.groups.tag,
annotation: match.groups.annotation || "",
});
}
return tags;
}, []);
return tags;
},
releases() {
return this.list().filter((tag) =>
tag.annotation.startsWith("titan-release:")
);
},
latestReleases(
pkgs = npm.getAllPackages(),
tags = this.releases(npm.getProjectRoot())
) {
const latest = new Map();
for (const tag of tags) {
const { name, version } = npm.parseNameWithVersion(tag.name);
if (!latest.has(name)) {
if (pkgs.has(name)) {
latest.set(name, {
name,
tag,
version,
pkg: pkgs.get(name),
});
}
}
}
return latest;
},
create(name, message) {
execSync(`git tag ${name} ${message ? `-m "${message}"` : ""}`, {
cwd: npm.getProjectRoot(),
stdio: "pipe",
});
},
};
const config = {
get(key) {
const result = execSync(`git config --get ${key}`, {
encoding: "utf8",
stdio: "pipe",
});
return result.trim();
},
};
const getChangedPackages = (from = undefined, to = "HEAD") => {
const pkgs = npm.getAllPackages();
const tags = tag.releases();
const releases = tag.latestReleases(pkgs, tags);
const changed = [];
for (const pkg of pkgs.values()) {
if (releases.has(pkg.config.name)) {
const release = releases.get(pkg.config.name);
const changes = getChangesBetween(
from || release.tag.name,
to,
release.pkg
);
if (changes.length > 0) {
changed.push({
release,
changes,
pkg,
});
}
} else {
// Packages without a release are considered changed
changed.push({
release: null,
changes: null,
pkg,
});
}
}
return changed;
};
module.exports = {
init,
add,
commit,
status,
printChanges,
diff,
log,
getCommitDataBetween,
getChangesBetween,
changedSince,
getUpgradeBetween,
getAllUpgradesBetween,
tag,
config,
getChangedPackages,
};