From 964f8211ae7c70b31b2a1ce123b7f3b66b840c4e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:46:09 -0500 Subject: [PATCH] feat: implement line counter as node console app in typescript --- .gitignore | 2 + LICENSE.md | 21 ++++++++ README.md | 16 ++++++ package-lock.json | 51 ++++++++++++++++++ package.json | 34 ++++++++++++ src/index.ts | 58 +++++++++++++++++++++ src/utils.ts | 129 ++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 18 +++++++ 8 files changed, 329 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b7c0e4d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) +C +opyright (c) 2025 Stevan Freeborn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..86d2180 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Line Counter + +This is a console app that monitors a git repository and reports on the number of lines changed in the repository while it is running. The results are displayed in the console and are written to a file. + +## Installation + +```pwsh +npm install -g @stevanfreeborn/line-counter +``` + +## Usage + +```pwsh +line-counter [polling-interval-in-seconds] +``` + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b655a72 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@stevanfreeborn/line-counter", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@stevanfreeborn/line-counter", + "version": "0.0.0", + "license": "MIT", + "bin": { + "line-counter": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "typescript": "^5.8.3" + } + }, + "node_modules/@types/node": { + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7570d01 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "@stevanfreeborn/line-counter", + "version": "0.0.1", + "description": "A simple line counter for reporting the number of lines changed in committed and uncommitted files.", + "main": "dist/index.js", + "bin": { + "line-counter": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js", + "prepare": "npm run build" + }, + "keywords": [ + "line", + "counter", + "git", + "commit", + "uncommitted" + ], + "author": "Stevan Freeborn", + "license": "MIT", + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE.md" + ], + "devDependencies": { + "@types/node": "^22.14.1", + "typescript": "^5.8.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a2f7614 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import readline from "readline"; +import fs from "fs"; +import { changeToRepoDirectory, ensureGitInstalled, getCommitDiff, parseArgs, wait } from "./utils.js"; + +ensureGitInstalled(); + +const args = parseArgs(process); + +let exitRequested = false; + +readline.emitKeypressEvents(process.stdin); + +if (process.stdin.isTTY) { + process.stdin.setRawMode(true); +} + +process.stdin.on("keypress", (_, key) => { + if (key.name === "c" && key.ctrl) { + console.log("Ctrl+C pressed. Exiting and restoring original directory..."); + process.chdir(args.originalLocation); + exitRequested = true; + process.exit(0); + } +}); + +changeToRepoDirectory(args.repoPath); + +const startCommit = execSync("git rev-parse HEAD", { + encoding: "utf-8", +}).trim(); + +try { + while (exitRequested === false) { + const committedDiff = getCommitDiff(startCommit); + const uncommittedDiff = getCommitDiff(); + + const totalAdded = committedDiff.added + uncommittedDiff.added; + const totalRemoved = committedDiff.removed + uncommittedDiff.removed; + const totalChanged = totalAdded + totalRemoved; + + const output = `Total lines changed: ${totalChanged} (Committed: +${committedDiff.added}/-${committedDiff.removed}, Uncommitted: +${uncommittedDiff.added}/-${uncommittedDiff.removed})`; + + console.clear(); + console.log(output); + + fs.writeFileSync(args.outputPath, output, { encoding: "utf-8" }); + + await wait(args.pollIntervalSeconds); + } +} catch (error) { + console.error("An error occurred: ", error); + process.exit(1); +} finally { + process.chdir(args.originalLocation); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..fe80815 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,129 @@ +import { execSync } from "child_process"; +import fs from "fs"; + +export function parseArgs(process: NodeJS.Process) { + const repoPath = process.argv[2]; + const outputPath = process.argv[3]; + const pollInterval = process.argv[4]; + + const usageMessage = ` +Usage: line-counter [poll-interval] + +Arguments: + path-to-repo Path to the repository to monitor. + output-path Path to the output directory where the results will be saved. + poll-interval Interval in seconds to poll for changes (default: 10s). +`; + + if (process.argv[2] === "--help") { + console.log(usageMessage); + process.exit(0); + } + + if (repoPath === undefined) { + console.error("Please provide a path to the repository."); + console.error(usageMessage); + process.exit(1); + } + + const repoPathExists = fs.existsSync(repoPath); + + if (!repoPathExists) { + console.error(`The specified repository path does not exist: ${repoPath}`); + process.exit(1); + } + + if (outputPath === undefined) { + console.error("Please provide a path to the output directory."); + console.error(usageMessage); + process.exit(1); + } + + const outputPathExists = fs.existsSync(outputPath); + + if (!outputPathExists) { + console.error(`The specified output path does not exist: ${outputPath}`); + process.exit(1); + } + + const outputPathIsDirectory = fs.lstatSync(outputPath).isDirectory(); + + if (outputPathIsDirectory === false) { + console.error( + `The specified output path is not a directory: ${outputPath}`, + ); + process.exit(1); + } + + const pollIntervalString = pollInterval || "10"; + const pollIntervalSeconds = parseInt(pollIntervalString, 10); + const originalLocation = process.cwd(); + + return { + repoPath, + outputPath: `${outputPath}/line-counter-output.txt`, + pollIntervalSeconds, + originalLocation, + }; +} + +export function ensureGitInstalled() { + try { + const gitVersion = execSync("git --version", { encoding: "utf-8" }); + const gitVersionRegex = /git version .+/; + + if (!gitVersionRegex.test(gitVersion)) { + console.error( + "Git is not installed or not found in PATH. Please install Git and try again.", + ); + process.exit(1); + } + } catch (error) { + console.error("Unable to detect if Git is installed."); + process.exit(1); + } +} + +export function changeToRepoDirectory(repoPath: string) { + try { + process.chdir(repoPath); + } catch (error) { + console.error(`Failed to change directory to ${repoPath}: `, error); + process.exit(1); + } +} + +export function getCommitDiff(startCommit?: string) { + try { + const command = startCommit + ? `git diff --shortstat ${startCommit} HEAD` + : "git diff --shortstat"; + + const diff = execSync(command, { + encoding: "utf-8", + }); + + const addMatch = diff.match(/(\d+) insertion/); + const removeMatch = diff.match(/(\d+) deletion/); + + let added = 0; + let removed = 0; + + if (addMatch && addMatch[1]) { + added = parseInt(addMatch[1], 10); + } + + if (removeMatch && removeMatch[1]) { + removed = parseInt(removeMatch[1], 10); + } + + return { added, removed }; + } catch (error) { + console.error("Error executing git diff: ", error); + process.exit(1); + } +} + +export async function wait(seconds: number) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..823f48d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "module": "NodeNext", + "outDir": "dist", + "lib": ["es2022"] + } +}