From 33329b5fa173771a3fd73f92b47d5da3ce9149a3 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 3 Oct 2026 22:51:54 -0700 Subject: [PATCH] feat: change @Last modified by only when the file's content was edited @Last modified by now records whoever last edited the file's content (everything outside the header), not whoever last ran fix-headers. The content counts as edited when the file's body, with the header taken out, differs from its body at git HEAD, or when HEAD does not have the file (new, untracked or ignored). Header rewrites fix-headers makes on its own (date format, epoch repair, fixCreatedDate, time zone conversion, frame, spacing, margin, @Project/@Filename/@Copyright) still restamp @Last modified time but keep the recorded editor. Outside a git work tree content edits cannot be detected and the recorded editor is kept. A content edit by the editor already recorded is restamped once when the recorded time is older than the file's last commit, so repeat edits move the time without making re-runs churn. @Author/@Email stay the original author unless forceAuthorUpdate is set. forceLastModifiedAuthorUpdate keeps its previous meaning (write the run identity on every file) and is documented as not needed for normal use. Closes #127 --- README.md | 41 +- src/cli.mjs | 4 +- src/core/fix-headers.mjs | 134 ++++--- src/header/parser.mjs | 21 +- src/utils/git.mjs | 33 +- tests/core-edge.test.vitest.mjs | 12 +- ...last-modified-content-edit.test.vitest.mjs | 356 ++++++++++++++++++ tests/sample-output.test.vitest.mjs | 18 +- types/src/core/fix-headers.d.mts | 10 +- types/src/header/parser.d.mts | 25 ++ types/src/utils/git.d.mts | 17 + 11 files changed, 594 insertions(+), 77 deletions(-) create mode 100644 tests/last-modified-content-edit.test.vitest.mjs diff --git a/README.md b/README.md index 4a941ad..140081e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **@cldmv/fix-headers** is a multi-language source header normalizer for Node.js projects. It scans a project's files, works out which project each file belongs to from the manifests around it (`package.json`, `pyproject.toml`, `composer.json`, `Cargo.toml`, `go.mod`, …), detects the author from git, and inserts or updates a standard header at the top of every file in that file's own comment syntax. -Headers stay correct without hand-editing: `@Date` follows the file's real creation time, `@Last modified time` only moves when the header changes, the `@Copyright` holder and years come from the manifest and the file's history, and `--check` validates existing headers in CI without writing anything. It runs as a `fix-headers` command line tool or as a library from ESM and CommonJS, and one shared config can serve every repository in an organisation. +Headers stay correct without hand-editing: `@Date` follows the file's real creation time, `@Author` keeps the original author, `@Last modified by` names whoever last edited the file's content and `@Last modified time` only moves when the header changes, the `@Copyright` holder and years come from the manifest and the file's history, and `--check` validates existing headers in CI without writing anything. It runs as a `fix-headers` command line tool or as a library from ESM and CommonJS, and one shared config can serve every repository in an organisation. > _One header format for every file in every repository, detected from the project itself and kept current by a single command._ @@ -37,6 +37,7 @@ Headers stay correct without hand-editing: `@Date` follows the file's real creat - Finds the project each file belongs to from its manifest (`package.json`, `pyproject.toml` / `setup.cfg` / `setup.py`, `composer.json`, `Cargo.toml`, `go.mod`), whatever the file's type - `@Project` is the name from that project's manifest, so a CSS, HTML, YAML or JSONC file in a Python, PHP, Rust or Go project gets that project's name too; the folder name is used only when no manifest provides one. Override it with `projectName`. See [Project name and root](#-project-name-and-root) - Auto-detects author and email from git config/commit history +- Keeps the original `@Author`, and changes `@Last modified by` only when the file's content (everything outside the header) was edited, so a run that only rewrites headers never claims other people's files. See [Author and last modified](#-author-and-last-modified) - Supports per-run overrides for every detected value - Supports folder inclusion and exclusion configuration; skips only what the project's ignore files (everything git honours) or your own exclusions say - Supports monorepos: every file resolves its own project from the nearest manifest in its parent tree @@ -124,8 +125,8 @@ Common CLI options: - `--verbose` - list updated files; together with `--sample-output` or `--diff`, also list each file's field differences (`authorName: found "X", expected "Y"`) - `--sample-output` - print the previous/new header and detected values for each changed file - `--diff` - print a unified diff of each changed file's header (implies sample output) -- `--force-author-update` -- `--force-last-modified-author-update` +- `--force-author-update` - replace an existing `@Author`/`@Email` with the detected identity (see [Author and last modified](#-author-and-last-modified)) +- `--force-last-modified-author-update` - write the detected identity as `@Last modified by` on every file, edited or not (rarely needed, see [Author and last modified](#-author-and-last-modified)) - `--use-gpg-signer-author` (the signing key's UID name, with the OpenPGP UID comment dropped) - `--cwd ` - `--input ` (repeatable) - process these files and folders instead of the whole project: the union of every value, each file once (`--input src/a.mjs --input src/b.mjs --input scripts`). A named file whose type cannot carry a header (see [Supported file types](#-supported-file-types)) is reported as `skipped: ()` and left unchanged @@ -178,8 +179,8 @@ Important options: - `authorName?: string` - `authorEmail?: string` - `company?: string` - appends to `@Author` as `Name ` -- `forceAuthorUpdate?: boolean` - force update `@Author`/`@Email` to detected or overridden current values -- `forceLastModifiedAuthorUpdate?: boolean` - force update `@Last modified by` to detected or overridden current values. Without this, an existing header's recorded `@Last modified by` identity is preserved and does not by itself trigger an update just because the running author differs (e.g. a different `git config user.name` than whoever last touched the file) +- `forceAuthorUpdate?: boolean` - replace an existing `@Author`/`@Email` with the detected (or overridden) identity. Off by default: `@Author` is the file's original author, and an existing value is never changed; a missing one is filled in. See [Author and last modified](#-author-and-last-modified) +- `forceLastModifiedAuthorUpdate?: boolean` - write the detected (or overridden) identity as `@Last modified by` on every file, whether its content was edited or not, and update files whose only difference is that identity. Off by default, and not needed for normal use: `@Last modified by` already becomes the run's identity on every file whose content was edited. See [Author and last modified](#-author-and-last-modified) - `useGpgSignerAuthor?: boolean` - take the detected `@Author` name from the user ID of the OpenPGP key git signs commits with (`user.signingkey`, read through `gpg.openpgp.program` / `gpg.program` / `gpg`; the first user ID that is not revoked or expired). The OpenPGP UID comment is dropped, so `Nate Corcoran (2023 PC) ` becomes `Nate Corcoran` (with `company: "CLDMV"`: `Nate Corcoran `). It describes whoever runs the tool, whatever the last commit is — a squash merge made by GitHub or a bot has no locally verifiable signer. With no readable OpenPGP signing key (none configured, `gpg.format` is `ssh`/`x509`, or gpg is unavailable) it falls back to the last commit's signer (`%GS`), then `git config user.name`, then the last commit's author - `companyName?: string` - the `@Copyright` holder for every file, instead of the one the project's manifests provide. There is no built-in default: unset, the holder comes from the manifests, and with none it is left out of the line (see [Copyright holder](#-copyright-holder)) - `copyrightStartYear?: number` - the `@Copyright` start year for every file. Unset (the default): each file's start year is the year of its own `@Date`, see [Copyright years](#-copyright-years) @@ -344,6 +345,34 @@ The filesystem creation time is the earlier of the file's birth time and its mod --- +## 👤 Author and last modified + +`@Author` / `@Email` name the file's original author, and `@Last modified by` / `@Last modified time` its last edit. + +- **`@Author` / `@Email`** are written once. An existing value is never changed, whoever runs the tool, unless `forceAuthorUpdate` is set. A file without one gets the detected identity. +- **`@Last modified by`** changes only when the file's **content** was edited: everything outside the header. It then becomes the identity detected for the run (`authorName` / `authorEmail`, or git as described under `useGpgSignerAuthor`). Changes fix-headers makes to the header on its own, such as the date format (`normalizeDateFormat`), an epoch repair, `@Date` (`fixCreatedDate`), a time zone conversion, the frame, spacing or margin, or the `@Project`, `@Filename` or `@Copyright` values, keep the recorded editor. +- **`@Last modified time`** is restamped with the time of the run whenever fix-headers rewrites the header, header-only rewrites included. A file whose header is already current is not touched. + +Whether the content was edited is decided against git `HEAD`. The header is taken out of the file and out of its `HEAD` version (`git show HEAD:`), along with the blank lines after it, and the rest is compared: + +| File | Content edited? | `@Author` | `@Last modified by` | `@Last modified time` | +| ------------------------------------------------------------ | -------------------------- | -------------------------- | ------------------- | --------------------- | +| Tracked, body the same as at `HEAD`, header rewritten | no | kept | kept | now | +| Tracked, body differs from `HEAD` | yes | kept | run identity | now | +| Not in `HEAD` (new, untracked or ignored, or no commits yet) | yes | kept (filled when missing) | run identity | now | +| Outside a git work tree, header rewritten | cannot tell, treated as no | kept | kept | now | +| Header already current, content not edited | no | unchanged | unchanged | unchanged | + +A missing field is filled with the run identity in every row, and a file without a header gets the run identity in both. Adding a header to a committed file is not a content edit, so a later header-only run by someone else keeps whoever was recorded then. + +`@Last modified time` also moves for a content edit by the editor already recorded: when the body differs from `HEAD` and the recorded time is older than the file's last commit, the edit has not been stamped yet, so it is restamped. Once stamped, the time is newer than the last commit, and running fix-headers again changes nothing until the next commit. + +The check compares the working tree with `HEAD`, so run fix-headers before committing (a pre-commit hook, or `npm run fix:headers` before `git commit`). Content committed without a run is not detected later: at that point the body matches `HEAD`. + +`forceLastModifiedAuthorUpdate` writes the run identity as `@Last modified by` on every file, edited or not. It is the old behaviour for configurations that relied on it; with content-edit detection it is not needed for normal use. + +--- + ## 📆 Copyright years `@Copyright: Copyright (c) - All rights reserved.` @@ -541,7 +570,7 @@ With `sampleOutput: true` (CLI: `--sample-output` or `--diff`), every changed en - `issues` - one `{ field, previous, detected }` entry per header field whose written value differs from the existing header, in header order. Fields: `projectName`, `filename`, `createdAt`, `authorName`, `authorEmail`, `lastModifiedByName`, `lastModifiedByEmail`, `lastModifiedAt`, `copyrightStartYear`, `copyrightEndYear`, `companyName`. Values are the field text as written in the header (dates keep their `date (timestamp)` form); `previous` is `null` when the field was missing. - `detectedValues` - the metadata resolved for the file. `projectNameSource` says where `projectName` came from: `{ from: "manifest", driver, manifest, dir }` (the driver, its manifest and the folder it sits in), `{ from: "folder", dir }` (the project root's folder name) or `{ from: "option" }` (`projectName`). `copyrightStartYear` is the start year written for the file, and `copyrightStartYearSource` says where it came from: `"option"` (`copyrightStartYear`) or `"created-date"` (the year of the file's `@Date`, see [Copyright years](#-copyright-years)). The run-level `result.metadata.copyrightStartYear` is the `copyrightStartYear` option, or `null` when it is not set. -`issues` compares the existing header against what is actually written, not against the raw detected metadata. fix-headers preserves an existing `@Author`/`@Email` and `@Last modified by` identity unless `forceAuthorUpdate` / `forceLastModifiedAuthorUpdate` is set, so those fields only appear when they really change. An updated file always gets a fresh `@Last modified time`, so `lastModifiedAt` is listed for every changed file that already had a header. +`issues` compares the existing header against what is actually written, not against the raw detected metadata. fix-headers preserves an existing `@Author`/`@Email` unless `forceAuthorUpdate` is set, and an existing `@Last modified by` unless the file's content was edited or `forceLastModifiedAuthorUpdate` is set (see [Author and last modified](#-author-and-last-modified)), so those fields only appear when they really change. An updated file always gets a fresh `@Last modified time`, so `lastModifiedAt` is listed for every changed file that already had a header. ```js const { changes } = await fixHeaders({ dryRun: true, sampleOutput: true }); diff --git a/src/cli.mjs b/src/cli.mjs index 5f64c9a..6eb5ad6 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -8,7 +8,7 @@ * @Email: * ----- * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-10-02T12:28:11-07:00 (1790969291) + * @Last modified time: 2026-10-03T22:50:30-07:00 (1791093030) * ----- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. * @@ -24,7 +24,7 @@ import fixHeaders from "./fix-header.mjs"; * @module fix-headers/cli */ -const HELP_TEXT = `fix-headers CLI\n\nUsage:\n fix-headers [options]\n\nOptions:\n -h, --help Show help\n --dry-run Compute changes without writing files\n --check Validate header dates without writing; exit 1 on date drift\n --fix-created-date Move an existing @Date back to the oldest of git first commit / file creation\n --strict-created-date With --check, fail when @Date is later than git first commit / file creation\n --normalize-date-format Write every header date in the ISO 8601 T-form (git %aI)\n --timezone Write new dates in an IANA time zone (e.g. America/Los_Angeles, UTC); the instant never changes\n --convert-timezone With --timezone, also rewrite existing @Date/@Last modified time values into that zone\n --json Print JSON output\n --verbose Print updated file paths in summary mode; with --sample-output or --diff, also print each file's field differences\n --sample-output Show previous/new header sample for changed files\n --diff Print a unified diff of each changed file's header (implies sample output)\n --force-author-update Always update @Author/@Email to detected/current values\n --force-last-modified-author-update Always update @Last modified by to detected/current values\n --use-gpg-signer-author Use signed-commit UID (%GS) for detected @Author\n --cwd Working directory for project detection\n --input File or folder to process instead of the whole project (repeatable)\n --include-folder Include folder (repeatable)\n --include-folder-non-recursive Include only a folder's own files, not its subfolders (repeatable)\n --exclude-folder Exclude folder name/path (repeatable)\n --include-extension Include extension (repeatable)\n --enable-detector Enable only specific detector (repeatable)\n --disable-detector Disable detector by id (repeatable)\n --force-detector Use a force-only detector, e.g. markdown (HTML-comment headers in .md files) (repeatable)\n --project-name Override project name\n --language Override language id\n --project-root Override project root\n --marker Override marker filename\n --author-name Override author name\n --author-email Override author email\n --company Append company suffix to @Author (Name )\n --company-name @Copyright holder (default: the project manifest's author; omitted when none)\n --copyright-start-year Set the copyright start year (default: each file's @Date year)\n --spacing Empty comment lines just inside the header's opening and closing (default: 1)\n --margin Blank lines between the header and the file's next content (default: 2)\n --config Load JSON options file; its 'extends' can pull in shared configs (URL, package or path)\n\nExamples:\n fix-headers --dry-run --include-folder src\n fix-headers --dry-run --diff --verbose\n fix-headers --check --verbose\n fix-headers --timezone America/Los_Angeles --convert-timezone\n fix-headers --project-name @scope/pkg --company-name "Catalyzed Motivation Inc."\n`; +const HELP_TEXT = `fix-headers CLI\n\nUsage:\n fix-headers [options]\n\nOptions:\n -h, --help Show help\n --dry-run Compute changes without writing files\n --check Validate header dates without writing; exit 1 on date drift\n --fix-created-date Move an existing @Date back to the oldest of git first commit / file creation\n --strict-created-date With --check, fail when @Date is later than git first commit / file creation\n --normalize-date-format Write every header date in the ISO 8601 T-form (git %aI)\n --timezone Write new dates in an IANA time zone (e.g. America/Los_Angeles, UTC); the instant never changes\n --convert-timezone With --timezone, also rewrite existing @Date/@Last modified time values into that zone\n --json Print JSON output\n --verbose Print updated file paths in summary mode; with --sample-output or --diff, also print each file's field differences\n --sample-output Show previous/new header sample for changed files\n --diff Print a unified diff of each changed file's header (implies sample output)\n --force-author-update Replace an existing @Author/@Email with the detected/current values\n --force-last-modified-author-update Write the detected identity as @Last modified by on every file, edited or not\n --use-gpg-signer-author Use signed-commit UID (%GS) for detected @Author\n --cwd Working directory for project detection\n --input File or folder to process instead of the whole project (repeatable)\n --include-folder Include folder (repeatable)\n --include-folder-non-recursive Include only a folder's own files, not its subfolders (repeatable)\n --exclude-folder Exclude folder name/path (repeatable)\n --include-extension Include extension (repeatable)\n --enable-detector Enable only specific detector (repeatable)\n --disable-detector Disable detector by id (repeatable)\n --force-detector Use a force-only detector, e.g. markdown (HTML-comment headers in .md files) (repeatable)\n --project-name Override project name\n --language Override language id\n --project-root Override project root\n --marker Override marker filename\n --author-name Override author name\n --author-email Override author email\n --company Append company suffix to @Author (Name )\n --company-name @Copyright holder (default: the project manifest's author; omitted when none)\n --copyright-start-year Set the copyright start year (default: each file's @Date year)\n --spacing Empty comment lines just inside the header's opening and closing (default: 1)\n --margin Blank lines between the header and the file's next content (default: 2)\n --config Load JSON options file; its 'extends' can pull in shared configs (URL, package or path)\n\nExamples:\n fix-headers --dry-run --include-folder src\n fix-headers --dry-run --diff --verbose\n fix-headers --check --verbose\n fix-headers --timezone America/Los_Angeles --convert-timezone\n fix-headers --project-name @scope/pkg --company-name "Catalyzed Motivation Inc."\n`; /** * Converts CLI flag token to camelCase key. diff --git a/src/core/fix-headers.mjs b/src/core/fix-headers.mjs index 8157cde..049a0a7 100644 --- a/src/core/fix-headers.mjs +++ b/src/core/fix-headers.mjs @@ -7,7 +7,7 @@ * @Email: * ----- * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-10-02T12:28:13-07:00 (1790969293) + * @Last modified time: 2026-10-03T22:50:30-07:00 (1791093030) * ----- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. * @@ -30,10 +30,10 @@ import { } from "../header/dates.mjs"; import { buildHeader } from "../header/template.mjs"; import { compareHeaderFields } from "../header/fields.mjs"; -import { findProjectHeader, replaceOrInsertHeader } from "../header/parser.mjs"; +import { extractHeaderlessBody, findProjectHeader, replaceOrInsertHeader } from "../header/parser.mjs"; import { createUnifiedDiff } from "../utils/diff.mjs"; import { readFileDates } from "../utils/fs.mjs"; -import { getGitCreationDate, getGitLastModifiedDate } from "../utils/git.mjs"; +import { getGitCreationDate, getGitLastModifiedDate, readGitHeadFile } from "../utils/git.mjs"; import { assertTimeZone, toDatePayload } from "../utils/time.mjs"; /** @@ -91,11 +91,11 @@ import { assertTimeZone, toDatePayload } from "../utils/time.mjs"; * - `issues` - one `{ field, previous, detected }` entry per header field whose written value * differs from the existing header. Values are the field text as written in the header * (dates keep their `date (timestamp)` form; `previous` is null when the field was missing). - * Fields fix-headers preserves - the original `@Author`/`@Email` and `@Last modified by` - * identity, unless `forceAuthorUpdate` / `forceLastModifiedAuthorUpdate` is set - are compared - * against what is actually written, so they only appear when they really change. Because an - * updated file gets a fresh `@Last modified time`, `lastModifiedAt` is listed for every - * changed file that already had a header. + * Fields fix-headers preserves - the original `@Author`/`@Email` (unless `forceAuthorUpdate`) + * and the `@Last modified by` identity (unless the file's content was edited, or + * `forceLastModifiedAuthorUpdate` is set) - are compared against what is actually written, so + * they only appear when they really change. Because an updated file gets a fresh + * `@Last modified time`, `lastModifiedAt` is listed for every changed file that already had a header. * - `detectedValues` - the metadata resolved for the file. `projectNameSource` says where * `projectName` came from: `{ from: "manifest", driver, manifest, dir }`, * `{ from: "folder", dir }` or `{ from: "option" }`. `companyName` is the `@Copyright` holder @@ -254,6 +254,29 @@ function extractHeaderLastModifiedAt(headerText) { }; } +/** + * Decides whether a file's content (everything outside its header) was edited, by comparing + * it with the file at git `HEAD`. The header is taken out of both sides first, so a run that + * only rewrites the header (dates, framing, spacing, margin, other fields, or adding one) does + * not count as an edit. + * - `tracked`: edited when the body differs from the body at `HEAD`. + * - `untracked`: a file `HEAD` does not have (new, ignored, or no commits yet) counts as edited. + * - `no-git`: outside a git work tree there is nothing to compare with, so it is not an edit + * and the recorded `@Last modified by` is kept. + * @param {string} content - Current file content. + * @param {string} filePath - Absolute file path. + * @param {Parameters[2]} syntaxOptions - Syntax resolution options. + * @returns {Promise<{edited: boolean, state: "tracked" | "untracked" | "no-git"}>} Edit verdict and git state. + */ +async function detectContentEdit(content, filePath, syntaxOptions) { + const head = await readGitHeadFile(filePath); + if (head.state !== "tracked") { + return { edited: head.state === "untracked", state: head.state }; + } + const edited = extractHeaderlessBody(head.content, filePath, syntaxOptions) !== extractHeaderlessBody(content, filePath, syntaxOptions); + return { edited, state: "tracked" }; +} + /** * @fileoverview Main header-fixing engine with auto-detection and override support. * @module fix-headers/core/fix-headers @@ -416,59 +439,56 @@ export async function fixHeaders(options = {}) { ); const shouldForceAuthorUpdate = effectiveOptions.forceAuthorUpdate === true; const shouldForceLastModifiedAuthorUpdate = effectiveOptions.forceLastModifiedAuthorUpdate === true; - - const comparisonHeader = buildHeader({ - absoluteFilePath: filePath, - language: fileMetadata.language, - syntaxOptions, - projectRoot: fileMetadata.projectRoot, - projectName: fileMetadata.projectName, - createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, - createdByEmail: shouldForceAuthorUpdate ? fileMetadata.authorEmail : existingIdentity.authorEmail || fileMetadata.authorEmail, - lastModifiedByName: shouldForceLastModifiedAuthorUpdate - ? fileMetadata.authorName - : existingLastModifiedIdentity.authorName || fileMetadata.authorName, - lastModifiedByEmail: shouldForceLastModifiedAuthorUpdate - ? fileMetadata.authorEmail - : existingLastModifiedIdentity.authorEmail || fileMetadata.authorEmail, - authorName: fileMetadata.authorName, - authorEmail: fileMetadata.authorEmail, - createdAt, - lastModifiedAt: comparisonLastModifiedAt, - copyrightStartYear, - companyName: fileMetadata.companyName, - currentYear - }); - + // @Last modified by names whoever last edited the file's content. It becomes the run's + // identity only when the content outside the header was edited (see detectContentEdit); + // a run that only rewrites header values keeps the recorded editor. + const contentEdit = await detectContentEdit(original, filePath, syntaxOptions); + const lastModifiedByRun = shouldForceLastModifiedAuthorUpdate || contentEdit.edited; + + /** + * Renders this file's header with the given @Last modified time. + * @param {{date: string, timestamp: number}} lastModifiedAt - Last-modified payload. + * @returns {string} Header block. + */ + const renderHeader = (lastModifiedAt) => + buildHeader({ + absoluteFilePath: filePath, + language: fileMetadata.language, + syntaxOptions, + projectRoot: fileMetadata.projectRoot, + projectName: fileMetadata.projectName, + createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, + createdByEmail: shouldForceAuthorUpdate ? fileMetadata.authorEmail : existingIdentity.authorEmail || fileMetadata.authorEmail, + lastModifiedByName: lastModifiedByRun + ? fileMetadata.authorName + : existingLastModifiedIdentity.authorName || fileMetadata.authorName, + lastModifiedByEmail: lastModifiedByRun + ? fileMetadata.authorEmail + : existingLastModifiedIdentity.authorEmail || fileMetadata.authorEmail, + authorName: fileMetadata.authorName, + authorEmail: fileMetadata.authorEmail, + createdAt, + lastModifiedAt, + copyrightStartYear, + companyName: fileMetadata.companyName, + currentYear + }); + + const comparisonHeader = renderHeader(comparisonLastModifiedAt); const comparisonReplacement = replaceOrInsertHeader(original, comparisonHeader, filePath, syntaxOptions); - const needsUpdate = comparisonReplacement.changed; + // A content edit made since the file's last commit, under a stamp older than that commit, + // has not been stamped yet: restamp it even when the editor is already the one recorded. + // Once stamped, the stamp is newer than the last commit, so re-running is a no-op. + const unstampedEdit = + contentEdit.edited && + repairedLastModifiedAt !== null && + gitLastUpdated !== null && + repairedLastModifiedAt.timestamp < gitLastUpdated.timestamp; + const needsUpdate = comparisonReplacement.changed || unstampedEdit; const finalLastModifiedAt = needsUpdate ? formatDate(toZone(toDatePayload(new Date()))) : comparisonLastModifiedAt; const lastModifiedAtSource = needsUpdate ? "current-time-on-change" : comparisonLastModifiedAtSource; - const header = needsUpdate - ? buildHeader({ - absoluteFilePath: filePath, - language: fileMetadata.language, - syntaxOptions, - projectRoot: fileMetadata.projectRoot, - projectName: fileMetadata.projectName, - createdByName: shouldForceAuthorUpdate ? fileMetadata.authorName : existingIdentity.authorName || fileMetadata.authorName, - createdByEmail: shouldForceAuthorUpdate ? fileMetadata.authorEmail : existingIdentity.authorEmail || fileMetadata.authorEmail, - lastModifiedByName: shouldForceLastModifiedAuthorUpdate - ? fileMetadata.authorName - : existingLastModifiedIdentity.authorName || fileMetadata.authorName, - lastModifiedByEmail: shouldForceLastModifiedAuthorUpdate - ? fileMetadata.authorEmail - : existingLastModifiedIdentity.authorEmail || fileMetadata.authorEmail, - authorName: fileMetadata.authorName, - authorEmail: fileMetadata.authorEmail, - createdAt, - lastModifiedAt: finalLastModifiedAt, - copyrightStartYear, - companyName: fileMetadata.companyName, - currentYear - }) - : comparisonHeader; + const header = needsUpdate ? renderHeader(finalLastModifiedAt) : comparisonHeader; const replacement = needsUpdate ? replaceOrInsertHeader(original, header, filePath, syntaxOptions) : comparisonReplacement; /** @type {FixHeadersResult["changes"][number]} */ diff --git a/src/header/parser.mjs b/src/header/parser.mjs index 1a21601..a8d8ae1 100644 --- a/src/header/parser.mjs +++ b/src/header/parser.mjs @@ -7,7 +7,7 @@ * @Email: * ----- * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-10-02T12:28:16-07:00 (1790969296) + * @Last modified time: 2026-10-03T22:50:30-07:00 (1791093030) * ----- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. * @@ -151,3 +151,22 @@ export function replaceOrInsertHeader(content, newHeader, filePath = "", syntaxO const nextContent = `${before}${join(after)}`; return { nextContent, changed: nextContent !== content }; } + +/** + * Returns a file's content with its project header taken out: the file as it would read + * with no header at all. Blank lines between the header and the next content, and a body + * that is only whitespace, are dropped, so two versions of a file that differ only in their + * header (its fields, framing, spacing or margin, or whether it has one) give the same body. + * @param {string} content - File content. + * @param {string} [filePath=""] - File path used for syntax selection. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax resolution options. + * @returns {string} The content outside the header. + */ +export function extractHeaderlessBody(content, filePath = "", syntaxOptions = {}) { + const existing = findProjectHeader(content, filePath, syntaxOptions); + const { prefix, body } = existing + ? { prefix: content.slice(0, existing.start), body: content.slice(existing.end) } + : splitPreservedPrefix(filePath, content, syntaxOptions); + const rest = body.replace(/^\n+/, ""); + return `${prefix}${rest.trim() === "" ? "" : rest}`; +} diff --git a/src/utils/git.mjs b/src/utils/git.mjs index c8ef6fc..4730e75 100644 --- a/src/utils/git.mjs +++ b/src/utils/git.mjs @@ -7,13 +7,14 @@ * @Email: * ----- * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-10-02T12:28:16-07:00 (1790969296) + * @Last modified time: 2026-10-03T22:50:30-07:00 (1791093030) * ----- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. * */ import { execFile } from "node:child_process"; +import { basename, dirname } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); @@ -260,3 +261,33 @@ export async function getGitLastModifiedDate(cwd, filePath) { return { date: canonicalGitDate(date), timestamp }; } + +/** + * Largest committed file `readGitHeadFile` reads (git show's stdout buffer). + * @type {number} + */ +const HEAD_FILE_MAX_BUFFER = 256 * 1024 * 1024; + +/** + * Reads a file as it is at git `HEAD`, from the repository that contains it. + * @param {string} filePath - Absolute file path. + * @returns {Promise<{state: "no-git", content: null} | {state: "untracked", content: null} | {state: "tracked", content: string}>} + * `no-git` when the file is not inside a git work tree, `untracked` when `HEAD` has no such + * file (a new or ignored file, or a repository with no commits yet), otherwise the content at `HEAD`. + */ +export async function readGitHeadFile(filePath) { + const cwd = dirname(filePath); + if ((await runGit(cwd, ["rev-parse", "--is-inside-work-tree"])) !== "true") { + return { state: "no-git", content: null }; + } + try { + const { stdout } = await execFileAsync("git", ["show", `HEAD:./${basename(filePath)}`], { + cwd, + encoding: "utf8", + maxBuffer: HEAD_FILE_MAX_BUFFER + }); + return { state: "tracked", content: stdout }; + } catch { + return { state: "untracked", content: null }; + } +} diff --git a/tests/core-edge.test.vitest.mjs b/tests/core-edge.test.vitest.mjs index c02792a..e6a9121 100644 --- a/tests/core-edge.test.vitest.mjs +++ b/tests/core-edge.test.vitest.mjs @@ -7,7 +7,7 @@ * @Email: * ----- * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-10-02T12:28:17-07:00 (1790969297) + * @Last modified time: 2026-10-03T22:50:31-07:00 (1791093031) * ----- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. * @@ -206,7 +206,7 @@ describe("core edge coverage", () => { } }); - it("preserves original header author and last-modified identity when nothing else changes", async () => { + it("preserves original header author and last-modified identity of a committed, unedited file when nothing else changes", async () => { const workspace = await createWorkspace("core-edge-preserve-author"); const currentYear = new Date().getFullYear(); @@ -217,6 +217,9 @@ describe("core edge coverage", () => { `/**\n *\n *\t@Project: core-edge-preserve-author\n *\t@Filename: /src/one.mjs\n *\t@Date: 2026-01-01 00:00:00 +00:00 (1767225600)\n *\t@Author: Original Author\n *\t@Email: \n *\t-----\n *\t@Last modified by: Old Updater (old@example.com)\n *\t@Last modified time: 2026-01-02 00:00:00 +00:00 (1767312000)\n *\t-----\n *\t@Copyright: Copyright (c) 2026-${currentYear} Catalyzed Motivation Inc. All rights reserved.\n *\n */\n\n\nexport const one = true;\n` ); + // Committed and not edited since: the recorded editor is kept. + await initializeGitWorkspace(workspace); + const result = await coreFixHeaders({ cwd: workspace, input: "src/one.mjs", @@ -299,7 +302,7 @@ describe("core edge coverage", () => { } }); - it("does not cascade a forced author update into the last-modified identity of an unrelated rewrite", async () => { + it("does not cascade a forced author update into the last-modified identity of a file whose content was not edited", async () => { const workspace = await createWorkspace("core-edge-force-author-no-cascade"); try { @@ -309,6 +312,9 @@ describe("core edge coverage", () => { `/**\n *\n *\t@Project: core-edge-force-author-no-cascade\n *\t@Filename: /src/one.mjs\n *\t@Date: 2026-01-01 00:00:00 +00:00 (1767225600)\n *\t@Author: Original Author\n *\t@Email: \n *\t-----\n *\t@Last modified by: Old Updater (old@example.com)\n *\t@Last modified time: 2026-01-02 00:00:00 +00:00 (1767312000)\n *\t-----\n *\t@Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc All rights reserved.\n *\n */\n\n\nexport const one = true;\n` ); + // Committed and not edited since: the recorded editor is kept. + await initializeGitWorkspace(workspace); + await coreFixHeaders({ cwd: workspace, input: "src/one.mjs", diff --git a/tests/last-modified-content-edit.test.vitest.mjs b/tests/last-modified-content-edit.test.vitest.mjs new file mode 100644 index 0000000..a497234 --- /dev/null +++ b/tests/last-modified-content-edit.test.vitest.mjs @@ -0,0 +1,356 @@ +/** + * + * @Project: @cldmv/fix-headers + * @Filename: /tests/last-modified-content-edit.test.vitest.mjs + * @Date: 2026-10-03T22:49:12-07:00 (1791092952) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T22:50:31-07:00 (1791093031) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { fixHeaders } from "../src/core/fix-headers.mjs"; +import { extractHeaderlessBody } from "../src/header/parser.mjs"; +import { readGitHeadFile } from "../src/utils/git.mjs"; +import { FIXTURE_ROOT, cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; + +/** + * @fileoverview `@Last modified by` follows edits to a file's content, not header rewrites, + * and `@Author` is kept unless `forceAuthorUpdate` is set (CLDMV/fix-headers#127). + * + * Every fixture is a real git repository. The file is committed by "Original Author"; the run + * that follows is made by "Jane Contributor", whose identity comes only from a temporary global + * git config (`user.name`/`user.email`, no signing key), the way a contributor's own machine + * supplies it. `useGpgSignerAuthor` is on, as in the shared CLDMV config: with no signing key + * the run falls back to `user.name`. + */ + +const execFileAsync = promisify(execFile); + +const PROJECT = "lm-content-edit"; +const COMPANY = "Fixture Co."; +const ORIGINAL = { name: "Original Author", email: "original@example.com" }; +const JANE = { name: "Jane Contributor", email: "jane@example.com" }; +const CREATED = "2026-03-01 17:59:32 -08:00 (1772416772)"; +const MODIFIED = "2026-03-02 09:00:00 -08:00 (1772470800)"; +const BODY = "export const one = true;\n"; + +/** + * Renders a current header for `src/one.mjs` (as fix-headers writes it) followed by a body. + * @param {{ body?: string, author?: {name: string, email: string}, editor?: {name: string, email: string}, created?: string, modified?: string }} [parts={}] - Header values and body. + * @returns {string} File content. + */ +function fileWithHeader(parts = {}) { + const { body = BODY, author = ORIGINAL, editor = ORIGINAL, created = CREATED, modified = MODIFIED } = parts; + const year = new Date().getFullYear(); + return `/**\n *\n *\t@Project: ${PROJECT}\n *\t@Filename: /src/one.mjs\n *\t@Date: ${created}\n *\t@Author: ${author.name}\n *\t@Email: <${author.email}>\n *\t-----\n *\t@Last modified by: ${editor.name} (${editor.email})\n *\t@Last modified time: ${modified}\n *\t-----\n *\t@Copyright: Copyright (c) 2026-${year} ${COMPANY} All rights reserved.\n *\n */\n\n\n${body}`; +} + +/** + * Runs git in a workspace. + * @param {string} cwd - Workspace path. + * @param {string[]} args - Git arguments. + * @returns {Promise} Completion promise. + */ +async function git(cwd, args) { + await execFileAsync("git", args, { cwd }); +} + +/** + * Commits everything in the workspace as Original Author, leaving no identity in the repository config. + * @param {string} cwd - Workspace path. + * @param {string} message - Commit message. + * @returns {Promise} Completion promise. + */ +async function commitAsOriginal(cwd, message) { + await git(cwd, ["add", "."]); + await git(cwd, ["-c", `user.name=${ORIGINAL.name}`, "-c", `user.email=${ORIGINAL.email}`, "commit", "-q", "-m", message]); +} + +/** + * Creates a git workspace with a package.json and, when given, a committed `src/one.mjs`. + * @param {string} name - Workspace name. + * @param {string | null} committedContent - Content committed for `src/one.mjs`, or null for none. + * @returns {Promise} Workspace path. + */ +async function createRepo(name, committedContent) { + const workspace = await createWorkspace(name); + await writeWorkspaceFile(join(workspace, "package.json"), JSON.stringify({ name: PROJECT }, null, 2)); + if (committedContent !== null) { + await writeWorkspaceFile(join(workspace, "src", "one.mjs"), committedContent); + } + await git(workspace, ["init", "-q"]); + await commitAsOriginal(workspace, "initial"); + return workspace; +} + +/** + * Runs fix-headers on `src/one.mjs` as Jane (identity from the global git config). + * @param {string} workspace - Workspace path. + * @param {Record} [options={}] - Extra options. + * @returns {Promise<{ result: Awaited>, content: string }>} Run result and the file afterwards. + */ +async function runAsJane(workspace, options = {}) { + const result = await fixHeaders({ + cwd: workspace, + input: "src/one.mjs", + companyName: COMPANY, + useGpgSignerAuthor: true, + ...options + }); + return { result, content: await readFile(join(workspace, "src", "one.mjs"), "utf8") }; +} + +/** + * Reads one header tag value. + * @param {string} content - File content. + * @param {string} label - Tag label. + * @returns {string | undefined} The value. + */ +function tag(content, label) { + return content.match(new RegExp(`@${label}:[ \\t]*(.*)$`, "m"))?.[1]; +} + +describe("@Last modified by follows content edits (#127)", () => { + /** @type {string | undefined} */ + let previousGlobalConfig; + /** @type {string} */ + let globalConfigWorkspace; + + beforeEach(async () => { + previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; + globalConfigWorkspace = await createWorkspace("lm-global-config"); + const globalConfig = join(globalConfigWorkspace, "gitconfig"); + await writeFile(globalConfig, `[user]\n\tname = ${JANE.name}\n\temail = ${JANE.email}\n`, "utf8"); + process.env.GIT_CONFIG_GLOBAL = globalConfig; + }); + + afterEach(async () => { + process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; + await cleanupWorkspace(globalConfigWorkspace); + }); + + it("leaves a current, unedited file untouched", async () => { + const workspace = await createRepo("lm-no-change", fileWithHeader()); + try { + const { result, content } = await runAsJane(workspace); + expect(result.filesUpdated).toBe(0); + expect(content).toBe(fileWithHeader()); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("restamps the time but keeps @Last modified by and @Author when only the date format changes", async () => { + const workspace = await createRepo("lm-header-only", fileWithHeader()); + try { + const { result, content } = await runAsJane(workspace, { normalizeDateFormat: true }); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Date")).toBe("2026-03-01T17:59:32-08:00 (1772416772)"); + expect(tag(content, "Last modified time")).not.toBe(MODIFIED); + expect(tag(content, "Last modified time")).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2} \(\d+\)$/); + expect(tag(content, "Last modified by")).toBe(`${ORIGINAL.name} (${ORIGINAL.email})`); + expect(tag(content, "Author")).toBe(ORIGINAL.name); + expect(tag(content, "Email")).toBe(`<${ORIGINAL.email}>`); + expect(content.endsWith(`\n\n\n${BODY}`)).toBe(true); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("keeps @Last modified by for other header-only rewrites (stale fields, spacing, margin)", async () => { + const stale = fileWithHeader() + .replace(`@Project: ${PROJECT}`, "@Project: old-name") + .replace("@Filename: /src/one.mjs", "@Filename: /src/old.mjs") + .replace(" */\n\n\n", " */\n"); + const workspace = await createRepo("lm-header-fields", stale); + try { + const { result, content } = await runAsJane(workspace, { spacing: 2 }); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Project")).toBe(PROJECT); + expect(tag(content, "Filename")).toBe("/src/one.mjs"); + expect(tag(content, "Last modified time")).not.toBe(MODIFIED); + expect(tag(content, "Last modified by")).toBe(`${ORIGINAL.name} (${ORIGINAL.email})`); + expect(tag(content, "Author")).toBe(ORIGINAL.name); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("stamps the runner as @Last modified by when the body was edited, keeping @Author, and is then stable", async () => { + const workspace = await createRepo("lm-body-edit", fileWithHeader()); + try { + await writeFile(join(workspace, "src", "one.mjs"), fileWithHeader({ body: `${BODY}export const two = 2;\n` }), "utf8"); + + const first = await runAsJane(workspace); + expect(first.result.filesUpdated).toBe(1); + expect(tag(first.content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + expect(tag(first.content, "Last modified time")).not.toBe(MODIFIED); + expect(tag(first.content, "Author")).toBe(ORIGINAL.name); + expect(tag(first.content, "Email")).toBe(`<${ORIGINAL.email}>`); + expect(first.content).toContain("export const two = 2;"); + + const second = await runAsJane(workspace); + expect(second.result.filesUpdated).toBe(0); + expect(second.content).toBe(first.content); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("restamps the time of a new edit by the editor already recorded, once", async () => { + const committed = fileWithHeader({ editor: JANE }); + const workspace = await createRepo("lm-repeat-editor", committed); + try { + await writeFile(join(workspace, "src", "one.mjs"), fileWithHeader({ editor: JANE, body: `${BODY}// more\n` }), "utf8"); + + const first = await runAsJane(workspace); + expect(first.result.filesUpdated).toBe(1); + expect(tag(first.content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + expect(tag(first.content, "Last modified time")).not.toBe(MODIFIED); + + const second = await runAsJane(workspace); + expect(second.result.filesUpdated).toBe(0); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("still replaces @Author with forceAuthorUpdate, without touching @Last modified by of an unedited file", async () => { + const workspace = await createRepo("lm-force-author", fileWithHeader()); + try { + const { result, content } = await runAsJane(workspace, { forceAuthorUpdate: true }); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Author")).toBe(JANE.name); + expect(tag(content, "Email")).toBe(`<${JANE.email}>`); + expect(tag(content, "Last modified by")).toBe(`${ORIGINAL.name} (${ORIGINAL.email})`); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("stamps the runner on every changed file with forceLastModifiedAuthorUpdate", async () => { + const workspace = await createRepo("lm-force-last-modified", fileWithHeader()); + try { + const { result, content } = await runAsJane(workspace, { forceLastModifiedAuthorUpdate: true }); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + expect(tag(content, "Author")).toBe(ORIGINAL.name); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("treats an untracked file as edited", async () => { + const workspace = await createRepo("lm-untracked", null); + try { + await writeWorkspaceFile(join(workspace, "src", "one.mjs"), fileWithHeader()); + const { result, content } = await runAsJane(workspace); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + expect(tag(content, "Author")).toBe(ORIGINAL.name); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("gives a new file without a header the runner as author and editor", async () => { + const workspace = await createRepo("lm-new-no-header", null); + try { + await writeWorkspaceFile(join(workspace, "src", "one.mjs"), BODY); + const { result, content } = await runAsJane(workspace); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Author")).toBe(JANE.name); + expect(tag(content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + expect(content.endsWith(`\n\n\n${BODY}`)).toBe(true); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("does not count adding a header to a committed file as a content edit", async () => { + const workspace = await createRepo("lm-committed-no-header", BODY); + try { + const first = await runAsJane(workspace); + expect(first.result.filesUpdated).toBe(1); + // Nothing was recorded before, so both fields take the detected identity. + expect(tag(first.content, "Author")).toBe(JANE.name); + expect(tag(first.content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + + // Once committed, a later header-only change by someone else keeps Jane as the editor. + await commitAsOriginal(workspace, "add header"); + process.env.GIT_CONFIG_GLOBAL = join(globalConfigWorkspace, "other"); + await writeFile(process.env.GIT_CONFIG_GLOBAL, "[user]\n\tname = Someone Else\n\temail = else@example.com\n", "utf8"); + const second = await runAsJane(workspace, { normalizeDateFormat: true }); + expect(second.result.filesUpdated).toBe(1); + expect(tag(second.content, "Last modified by")).toBe(`${JANE.name} (${JANE.email})`); + expect(tag(second.content, "Author")).toBe(JANE.name); + } finally { + await cleanupWorkspace(workspace); + } + }); + + it("keeps the recorded editor outside a git work tree, where content edits cannot be detected", async () => { + const previousCeiling = process.env.GIT_CEILING_DIRECTORIES; + const workspace = await createWorkspace("lm-no-git"); + // Stop git's repository discovery at the fixture root, so this repository's own .git is not found. + process.env.GIT_CEILING_DIRECTORIES = FIXTURE_ROOT; + try { + await writeWorkspaceFile(join(workspace, "package.json"), JSON.stringify({ name: PROJECT }, null, 2)); + await writeWorkspaceFile(join(workspace, "src", "one.mjs"), fileWithHeader({ body: `${BODY}// edited\n` })); + expect((await readGitHeadFile(join(workspace, "src", "one.mjs"))).state).toBe("no-git"); + + const { result, content } = await runAsJane(workspace, { normalizeDateFormat: true, authorName: JANE.name, authorEmail: JANE.email }); + expect(result.filesUpdated).toBe(1); + expect(tag(content, "Last modified by")).toBe(`${ORIGINAL.name} (${ORIGINAL.email})`); + expect(tag(content, "Author")).toBe(ORIGINAL.name); + } finally { + if (previousCeiling === undefined) { + delete process.env.GIT_CEILING_DIRECTORIES; + } else { + process.env.GIT_CEILING_DIRECTORIES = previousCeiling; + } + await cleanupWorkspace(workspace); + } + }); +}); + +describe("readGitHeadFile", () => { + it("reads a committed file, including an empty one, and reports files HEAD does not have", async () => { + const workspace = await createRepo("lm-read-head", ""); + try { + expect(await readGitHeadFile(join(workspace, "src", "one.mjs"))).toEqual({ state: "tracked", content: "" }); + await writeWorkspaceFile(join(workspace, "src", "two.mjs"), BODY); + expect(await readGitHeadFile(join(workspace, "src", "two.mjs"))).toEqual({ state: "untracked", content: null }); + } finally { + await cleanupWorkspace(workspace); + } + }); +}); + +describe("extractHeaderlessBody", () => { + it("gives the same body whatever the header, framing or margin", () => { + const withHeader = fileWithHeader(); + expect(extractHeaderlessBody(withHeader, "/x/src/one.mjs")).toBe(BODY); + expect(extractHeaderlessBody(withHeader.replace(" */\n\n\n", " */\n"), "/x/src/one.mjs")).toBe(BODY); + expect(extractHeaderlessBody(BODY, "/x/src/one.mjs")).toBe(BODY); + expect(extractHeaderlessBody(`\n\n${BODY}`, "/x/src/one.mjs")).toBe(BODY); + }); + + it("keeps a preserved prefix and treats a whitespace-only body as empty", () => { + const shebang = "#!/usr/bin/env node\n"; + expect(extractHeaderlessBody(`${shebang}${fileWithHeader()}`, "/x/src/one.mjs")).toBe(`${shebang}${BODY}`); + expect(extractHeaderlessBody(`${shebang}${BODY}`, "/x/src/one.mjs")).toBe(`${shebang}${BODY}`); + expect(extractHeaderlessBody(fileWithHeader({ body: "\n \n" }), "/x/src/one.mjs")).toBe(""); + expect(extractHeaderlessBody("\n\n", "/x/src/one.mjs")).toBe(""); + }); +}); diff --git a/tests/sample-output.test.vitest.mjs b/tests/sample-output.test.vitest.mjs index e69c7bd..004c680 100644 --- a/tests/sample-output.test.vitest.mjs +++ b/tests/sample-output.test.vitest.mjs @@ -7,19 +7,23 @@ * @Email: * ----- * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-10-02T12:28:19-07:00 (1790969299) + * @Last modified time: 2026-10-03T22:50:31-07:00 (1791093031) * ----- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. * */ +import { execFile } from "node:child_process"; import { join } from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { parseCliArgs, runCli } from "../src/cli.mjs"; import { fixHeaders as coreFixHeaders } from "../src/core/fix-headers.mjs"; import { HEADER_FIELDS } from "../src/header/fields.mjs"; import { cleanupWorkspace, createWorkspace, writeWorkspaceFile } from "./helpers/workspace.mjs"; +const execFileAsync = promisify(execFile); + /** * Builds a stale header for `src/one.mjs` whose project, filename and copyright no longer match. * @param {string} [prefix=""] - Text placed before the header (e.g. a shebang line). @@ -30,7 +34,8 @@ function staleFile(prefix = "") { } /** - * Runs the core in dry-run sample mode against `src/one.mjs` of a fresh workspace. + * Runs the core in dry-run sample mode against `src/one.mjs` of a fresh workspace. The file is + * committed first, so its content counts as unedited and only the header is rewritten. * @param {string} name - Workspace/project name. * @param {string} content - Content for `src/one.mjs`. * @param {Record} [options={}] - Extra fixHeaders options. @@ -41,6 +46,15 @@ async function sampleFor(name, content, options = {}) { try { await writeWorkspaceFile(join(workspace, "package.json"), JSON.stringify({ name }, null, 2)); await writeWorkspaceFile(join(workspace, "src", "one.mjs"), content); + for (const args of [ + ["init"], + ["config", "user.name", "Sample Tester"], + ["config", "user.email", "sample@example.com"], + ["add", "."], + ["commit", "-m", "initial"] + ]) { + await execFileAsync("git", args, { cwd: workspace }); + } const result = await coreFixHeaders({ cwd: workspace, input: "src/one.mjs", diff --git a/types/src/core/fix-headers.d.mts b/types/src/core/fix-headers.d.mts index 31bf043..5a32985 100644 --- a/types/src/core/fix-headers.d.mts +++ b/types/src/core/fix-headers.d.mts @@ -71,11 +71,11 @@ export type DateRewrite = (payload: { * - `issues` - one `{ field, previous, detected }` entry per header field whose written value * differs from the existing header. Values are the field text as written in the header * (dates keep their `date (timestamp)` form; `previous` is null when the field was missing). - * Fields fix-headers preserves - the original `@Author`/`@Email` and `@Last modified by` - * identity, unless `forceAuthorUpdate` / `forceLastModifiedAuthorUpdate` is set - are compared - * against what is actually written, so they only appear when they really change. Because an - * updated file gets a fresh `@Last modified time`, `lastModifiedAt` is listed for every - * changed file that already had a header. + * Fields fix-headers preserves - the original `@Author`/`@Email` (unless `forceAuthorUpdate`) + * and the `@Last modified by` identity (unless the file's content was edited, or + * `forceLastModifiedAuthorUpdate` is set) - are compared against what is actually written, so + * they only appear when they really change. Because an updated file gets a fresh + * `@Last modified time`, `lastModifiedAt` is listed for every changed file that already had a header. * - `detectedValues` - the metadata resolved for the file. `projectNameSource` says where * `projectName` came from: `{ from: "manifest", driver, manifest, dir }`, * `{ from: "folder", dir }` or `{ from: "option" }`. `companyName` is the `@Copyright` holder diff --git a/types/src/header/parser.d.mts b/types/src/header/parser.d.mts index 44c5586..680dc45 100644 --- a/types/src/header/parser.d.mts +++ b/types/src/header/parser.d.mts @@ -49,3 +49,28 @@ export function replaceOrInsertHeader(content: string, newHeader: string, filePa nextContent: string; changed: boolean; }; +/** + * Returns a file's content with its project header taken out: the file as it would read + * with no header at all. Blank lines between the header and the next content, and a body + * that is only whitespace, are dropped, so two versions of a file that differ only in their + * header (its fields, framing, spacing or margin, or whether it has one) give the same body. + * @param {string} content - File content. + * @param {string} [filePath=""] - File path used for syntax selection. + * @param {{ language?: string, enabledDetectors?: string[], disabledDetectors?: string[], forcedDetectors?: string[], detectorSyntaxOverrides?: Record, spacing?: number, margin?: number }} [syntaxOptions={}] - Syntax resolution options. + * @returns {string} The content outside the header. + */ +export function extractHeaderlessBody(content: string, filePath?: string, syntaxOptions?: { + language?: string; + enabledDetectors?: string[]; + disabledDetectors?: string[]; + forcedDetectors?: string[]; + detectorSyntaxOverrides?: Record; + spacing?: number; + margin?: number; +}): string; diff --git a/types/src/utils/git.d.mts b/types/src/utils/git.d.mts index 38a707d..e6d21d8 100644 --- a/types/src/utils/git.d.mts +++ b/types/src/utils/git.d.mts @@ -68,3 +68,20 @@ export function getGitLastModifiedDate(cwd: string, filePath: string): Promise<{ date: string; timestamp: number; } | null>; +/** + * Reads a file as it is at git `HEAD`, from the repository that contains it. + * @param {string} filePath - Absolute file path. + * @returns {Promise<{state: "no-git", content: null} | {state: "untracked", content: null} | {state: "tracked", content: string}>} + * `no-git` when the file is not inside a git work tree, `untracked` when `HEAD` has no such + * file (a new or ignored file, or a repository with no commits yet), otherwise the content at `HEAD`. + */ +export function readGitHeadFile(filePath: string): Promise<{ + state: "no-git"; + content: null; +} | { + state: "untracked"; + content: null; +} | { + state: "tracked"; + content: string; +}>;