From f444a048763da0d7f7a8a3375cf5b81077d68752 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 14 Dec 2025 16:53:18 +0000 Subject: [PATCH] feat(mod_format): Add check-only formatting with interactive diff preview; make formatting default to dry-run and extend formatting API --- changelog.md | 12 ++++ package.json | 2 +- pnpm-lock.yaml | 17 ++--- ts/00_commitinfo_data.ts | 2 +- ts/classes.project.ts | 5 +- ts/gitzone.cli.ts | 2 + ts/mod_config/index.ts | 32 +++++++-- ts/mod_format/classes.baseformatter.ts | 92 +++++++++++++++++++++++++- ts/mod_format/index.ts | 45 ++++++++++--- ts/mod_format/interfaces.format.ts | 13 +++- 10 files changed, 192 insertions(+), 30 deletions(-) diff --git a/changelog.md b/changelog.md index d71b0c9..bcbe2c2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-12-14 - 2.7.0 - feat(mod_format) +Add check-only formatting with interactive diff preview; make formatting default to dry-run and extend formatting API + +- Add BaseFormatter.check(), displayDiff() and displayAllDiffs() to compute and render diffs without applying changes. +- Extend runFormatter API with new options: write (use to apply changes), checkOnly (only check for diffs), and showDiff (display diffs). When checkOnly is used, runFormatter returns an ICheckResult. +- Change default formatting behavior to dry-run. Use --write / -w to actually apply changes. CLI format command updated to respect --write/-w. +- Add formatNpmextraWithDiff in mod_config to preview diffs for npmextra.json and prompt the user before applying changes; calls to add/remove/clear registries and set access level now use this preview flow. +- Project.fromCwd now accepts an options object ({ requireProjectType?: boolean }) so callers can skip the projectType requirement when appropriate; runFormatter no longer requires projectType for certain formatters. +- Introduce a list of formatters that don't require projectType: npmextra, prettier, cleanup, packagejson. +- Export the ICheckResult type from the formatter module and update mod_format interfaces to include ICheckResult. +- Bump dependency @push.rocks/smartdiff to ^1.1.0. + ## 2025-12-14 - 2.6.1 - fix(npmextra) Normalize npmextra.json: move tsdoc legal entry and reposition @git.zone/cli configuration diff --git a/package.json b/package.json index ce6e4e7..a06f341 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@push.rocks/npmextra": "^5.3.3", "@push.rocks/projectinfo": "^5.0.2", "@push.rocks/smartcli": "^4.0.19", - "@push.rocks/smartdiff": "^1.0.3", + "@push.rocks/smartdiff": "^1.1.0", "@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartfs": "^1.2.0", "@push.rocks/smartgulp": "^3.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6f5282..a218ddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^4.0.19 version: 4.0.19 '@push.rocks/smartdiff': - specifier: ^1.0.3 - version: 1.0.3 + specifier: ^1.1.0 + version: 1.1.0 '@push.rocks/smartfile': specifier: ^13.1.2 version: 13.1.2 @@ -1042,8 +1042,8 @@ packages: '@push.rocks/smartdelay@3.0.5': resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} - '@push.rocks/smartdiff@1.0.3': - resolution: {integrity: sha512-cXUKj0KJBxnrZDN1Ztc2WiFRJM3vOTdQUdBfe6ar5NlKuXytSRMJqVL8IUbtWfMCSOx6HgWAUT7W68+/X2TG8w==} + '@push.rocks/smartdiff@1.1.0': + resolution: {integrity: sha512-AAz/unmko0C+g+60odOoK32PE3Ci3YLoB+zfg1LGLyVRCthcdzjqa1C2Km0MfG7IyJQKPdj8J5HPubtpm3ZeaQ==} '@push.rocks/smartdns@7.6.1': resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==} @@ -2536,9 +2536,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -6049,9 +6046,9 @@ snapshots: dependencies: '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartdiff@1.0.3': + '@push.rocks/smartdiff@1.1.0': dependencies: - fast-diff: 1.3.0 + diff: 8.0.2 '@push.rocks/smartdns@7.6.1': dependencies: @@ -8145,8 +8142,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-fifo@1.3.2: {} fast-json-stable-stringify@2.1.0: {} diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 489901c..9a8ebe7 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/cli', - version: '2.6.1', + version: '2.7.0', description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.' } diff --git a/ts/classes.project.ts b/ts/classes.project.ts index 4fc6570..a1e2231 100644 --- a/ts/classes.project.ts +++ b/ts/classes.project.ts @@ -8,10 +8,11 @@ import type { TGitzoneProjectType } from './classes.gitzoneconfig.js'; * the Project class is a tool to work with a gitzone project */ export class Project { - public static async fromCwd() { + public static async fromCwd(options: { requireProjectType?: boolean } = {}) { const gitzoneConfig = await GitzoneConfig.fromCwd(); const project = new Project(gitzoneConfig); - if (!project.gitzoneConfig.data.projectType) { + const requireProjectType = options.requireProjectType ?? true; + if (requireProjectType && !project.gitzoneConfig.data.projectType) { throw new Error('Please define a project type'); } return project; diff --git a/ts/gitzone.cli.ts b/ts/gitzone.cli.ts index 28ca5b9..1d6b7ca 100644 --- a/ts/gitzone.cli.ts +++ b/ts/gitzone.cli.ts @@ -80,7 +80,9 @@ export let run = async () => { } // Handle format with options + // Default is dry-mode, use --write/-w to apply changes await modFormat.run({ + write: argvArg.write || argvArg.w, dryRun: argvArg['dry-run'], yes: argvArg.yes, planOnly: argvArg['plan-only'], diff --git a/ts/mod_config/index.ts b/ts/mod_config/index.ts index d75ffbb..1b66df9 100644 --- a/ts/mod_config/index.ts +++ b/ts/mod_config/index.ts @@ -2,10 +2,32 @@ import * as plugins from './mod.plugins.js'; import { ReleaseConfig } from './classes.releaseconfig.js'; -import { runFormatter } from '../mod_format/index.js'; +import { runFormatter, type ICheckResult } from '../mod_format/index.js'; export { ReleaseConfig }; +/** + * Format npmextra.json with diff preview + * Shows diff first, asks for confirmation, then applies + */ +async function formatNpmextraWithDiff(): Promise { + // Check for diffs first + const checkResult = await runFormatter('npmextra', { + checkOnly: true, + showDiff: true, + }) as ICheckResult | void; + + if (checkResult && checkResult.hasDiff) { + const shouldApply = await plugins.smartinteract.SmartInteract.getCliConfirmation( + 'Apply formatting changes to npmextra.json?', + true + ); + if (shouldApply) { + await runFormatter('npmextra', { silent: true }); + } + } +} + export const run = async (argvArg: any) => { const command = argvArg._?.[1]; const value = argvArg._?.[2]; @@ -149,8 +171,8 @@ async function handleAdd(url?: string): Promise { if (added) { await config.save(); - await runFormatter('npmextra', { silent: true }); plugins.logger.log('success', `Added registry: ${url}`); + await formatNpmextraWithDiff(); } else { plugins.logger.log('warn', `Registry already exists: ${url}`); } @@ -185,8 +207,8 @@ async function handleRemove(url?: string): Promise { if (removed) { await config.save(); - await runFormatter('npmextra', { silent: true }); plugins.logger.log('success', `Removed registry: ${url}`); + await formatNpmextraWithDiff(); } else { plugins.logger.log('warn', `Registry not found: ${url}`); } @@ -212,8 +234,8 @@ async function handleClear(): Promise { if (confirmed) { config.clearRegistries(); await config.save(); - await runFormatter('npmextra', { silent: true }); plugins.logger.log('success', 'All registries cleared.'); + await formatNpmextraWithDiff(); } else { plugins.logger.log('info', 'Operation cancelled.'); } @@ -252,8 +274,8 @@ async function handleAccessLevel(level?: string): Promise { config.setAccessLevel(level as 'public' | 'private'); await config.save(); - await runFormatter('npmextra', { silent: true }); plugins.logger.log('success', `Access level set to: ${level}`); + await formatNpmextraWithDiff(); } /** diff --git a/ts/mod_format/classes.baseformatter.ts b/ts/mod_format/classes.baseformatter.ts index 3cd43b3..4138c09 100644 --- a/ts/mod_format/classes.baseformatter.ts +++ b/ts/mod_format/classes.baseformatter.ts @@ -1,6 +1,6 @@ import * as plugins from './mod.plugins.js'; import { FormatContext } from './classes.formatcontext.js'; -import type { IPlannedChange } from './interfaces.format.js'; +import type { IPlannedChange, ICheckResult } from './interfaces.format.js'; import { Project } from '../classes.project.js'; export abstract class BaseFormatter { @@ -79,4 +79,94 @@ export abstract class BaseFormatter { protected async shouldProcessFile(filepath: string): Promise { return true; } + + /** + * Check for diffs without applying changes + * Returns information about what would change + */ + async check(): Promise { + const changes = await this.analyze(); + const diffs: ICheckResult['diffs'] = []; + + for (const change of changes) { + // Skip generic changes that don't have actual content + if (change.path === '') { + continue; + } + + if (change.type === 'modify' || change.type === 'create') { + // Read current content if file exists + let currentContent: string | undefined; + try { + currentContent = await plugins.smartfs.file(change.path).encoding('utf8').read() as string; + } catch { + // File doesn't exist yet + currentContent = undefined; + } + + const newContent = change.content; + + // Check if there's an actual diff + if (currentContent !== newContent && newContent !== undefined) { + diffs.push({ + path: change.path, + type: change.type, + before: currentContent, + after: newContent, + }); + } + } else if (change.type === 'delete') { + // Check if file exists before marking for deletion + try { + const currentContent = await plugins.smartfs.file(change.path).encoding('utf8').read() as string; + diffs.push({ + path: change.path, + type: 'delete', + before: currentContent, + after: undefined, + }); + } catch { + // File doesn't exist, nothing to delete + } + } + } + + return { + hasDiff: diffs.length > 0, + diffs, + }; + } + + /** + * Display a single diff using smartdiff + */ + displayDiff(diff: ICheckResult['diffs'][0]): void { + console.log(`\n--- ${diff.path}`); + if (diff.before && diff.after) { + console.log(plugins.smartdiff.formatLineDiffForConsole(diff.before, diff.after)); + } else if (diff.after && !diff.before) { + console.log(' (new file)'); + // Show first few lines of new content + const lines = diff.after.split('\n').slice(0, 10); + lines.forEach(line => console.log(` + ${line}`)); + if (diff.after.split('\n').length > 10) { + console.log(' ... (truncated)'); + } + } else if (diff.before && !diff.after) { + console.log(' (file will be deleted)'); + } + } + + /** + * Display all diffs from a check result + */ + displayAllDiffs(result: ICheckResult): void { + if (!result.hasDiff) { + console.log(' No changes detected'); + return; + } + for (const diff of result.diffs) { + this.displayDiff(diff); + } + } } diff --git a/ts/mod_format/index.ts b/ts/mod_format/index.ts index 6194162..edf9673 100644 --- a/ts/mod_format/index.ts +++ b/ts/mod_format/index.ts @@ -19,7 +19,8 @@ import { CopyFormatter } from './formatters/copy.formatter.js'; export let run = async ( options: { - dryRun?: boolean; + write?: boolean; // Explicitly write changes (default: false, dry-mode) + dryRun?: boolean; // Deprecated, kept for compatibility yes?: boolean; planOnly?: boolean; savePlan?: string; @@ -35,7 +36,11 @@ export let run = async ( setVerboseMode(true); } - const project = await Project.fromCwd(); + // Determine if we should write changes + // Default is dry-mode (no writing) unless --write/-w is specified + const shouldWrite = options.write ?? (options.dryRun === false); + + const project = await Project.fromCwd({ requireProjectType: false }); const context = new FormatContext(); // Cache system removed - no longer needed const planner = new FormatPlanner(); @@ -127,9 +132,9 @@ export let run = async ( return; } - // Dry-run mode - if (options.dryRun) { - logger.log('info', 'Dry-run mode - no changes will be made'); + // Dry-run mode (default behavior) + if (!shouldWrite) { + logger.log('info', 'Dry-run mode - use --write (-w) to apply changes'); return; } @@ -197,14 +202,27 @@ export const handleCleanBackups = async (): Promise => { ); }; +// Import the ICheckResult type for external use +import type { ICheckResult } from './interfaces.format.js'; +export type { ICheckResult }; + +// Formatters that don't require projectType to be set +const formattersNotRequiringProjectType = ['npmextra', 'prettier', 'cleanup', 'packagejson']; + /** * Run a single formatter by name (for use by other modules) */ export const runFormatter = async ( formatterName: string, - options: { silent?: boolean } = {} -): Promise => { - const project = await Project.fromCwd(); + options: { + silent?: boolean; + checkOnly?: boolean; // Only check for diffs, don't apply + showDiff?: boolean; // Show the diff output + } = {} +): Promise => { + // Determine if this formatter requires projectType + const requireProjectType = !formattersNotRequiringProjectType.includes(formatterName); + const project = await Project.fromCwd({ requireProjectType }); const context = new FormatContext(); // Map formatter names to classes @@ -227,6 +245,17 @@ export const runFormatter = async ( } const formatter = new FormatterClass(context, project); + + // Check-only mode: just check for diffs and optionally display them + if (options.checkOnly) { + const result = await formatter.check(); + if (result.hasDiff && options.showDiff) { + formatter.displayAllDiffs(result); + } + return result; + } + + // Normal mode: analyze and apply changes const changes = await formatter.analyze(); for (const change of changes) { diff --git a/ts/mod_format/interfaces.format.ts b/ts/mod_format/interfaces.format.ts index 27bed2c..264001b 100644 --- a/ts/mod_format/interfaces.format.ts +++ b/ts/mod_format/interfaces.format.ts @@ -39,7 +39,18 @@ export type IPlannedChange = { path: string; module: string; description: string; - content?: string; // For create/modify operations + content?: string; // New content for create/modify operations + originalContent?: string; // Original content for comparison diff?: string; size?: number; }; + +export interface ICheckResult { + hasDiff: boolean; + diffs: Array<{ + path: string; + type: 'create' | 'modify' | 'delete'; + before?: string; + after?: string; + }>; +}