diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd8827..7b3f591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Safe Prune Inspection**: Added `inspectPrunableObjects()` for streaming the + loose unreachable objects Git would prune before a canonical UTC cutoff. + +### Fixed + +- **Sanitizer Memoization**: Replaced the lossy command-cache key so commands + with different safety properties cannot collide. + ## [3.0.3] - 2026-05-06 ### Fixed diff --git a/README.md b/README.md index 13fda32..4978fa2 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,24 @@ const commitSha = await git.createCommitFromFiles({ }); ``` +### Non-Mutating Prune Inspection + +Stream loose unreachable objects that Git considers older than a canonical UTC +cutoff. This API always invokes Git with `--dry-run --no-progress`; +unrestricted `git prune` remains prohibited by the command sanitizer. + +```javascript +const plumbing = await GitPlumbing.createDefault({ cwd: './my-repo' }); +const stream = await plumbing.inspectPrunableObjects({ + expiresBefore: '2026-07-01T00:00:00.000Z' +}); + +for await (const chunk of stream) { + // Parse Git's ` ` records incrementally. +} +await stream.finished; +``` + ## 📖 Deep Dives - [**Standard Guide**](./GUIDE.md) - From zero to atomic commits. diff --git a/index.js b/index.js index 9ffe32b..2b36d15 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,27 @@ import GitPersistenceService from './src/domain/services/GitPersistenceService.j // Infrastructure import GitStream from './src/infrastructure/GitStream.js'; +const CANONICAL_UTC_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function normalizePruneExpiry(expiresBefore) { + if (typeof expiresBefore !== 'string' || !CANONICAL_UTC_TIMESTAMP.test(expiresBefore)) { + throw new InvalidArgumentError( + 'expiresBefore must be a canonical UTC timestamp', + 'GitPlumbing.inspectPrunableObjects', + { expiresBefore } + ); + } + const parsed = Date.parse(expiresBefore); + if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== expiresBefore) { + throw new InvalidArgumentError( + 'expiresBefore must be a valid canonical UTC timestamp', + 'GitPlumbing.inspectPrunableObjects', + { expiresBefore } + ); + } + return expiresBefore; +} + /** * Named exports for public API */ @@ -199,6 +220,21 @@ export default class GitPlumbing { } } + /** + * Streams the loose unreachable objects Git would prune before a cutoff. + * The command is unconditionally dry-run and never mutates repository state. + * + * @param {Object} options + * @param {string} options.expiresBefore - Canonical UTC timestamp. + * @returns {Promise} + */ + async inspectPrunableObjects({ expiresBefore } = {}) { + const expiry = normalizePruneExpiry(expiresBefore); + return await this.executeStream({ + args: ['prune', '--dry-run', '--verbose', '--no-progress', `--expire=${expiry}`] + }); + } + /** * Executes a git command and returns both stdout and exit status without throwing on non-zero exit. * @param {Object} options diff --git a/src/domain/services/CommandSanitizer.js b/src/domain/services/CommandSanitizer.js index e2facfa..ff8903d 100644 --- a/src/domain/services/CommandSanitizer.js +++ b/src/domain/services/CommandSanitizer.js @@ -44,7 +44,8 @@ export default class CommandSanitizer { 'init', 'config', 'log', - 'show' + 'show', + 'prune' ]); /** @@ -98,9 +99,35 @@ export default class CommandSanitizer { '--graph', '--decorate', '--no-decorate', '--source', '--no-walk', '--stdin', '--cherry', '--cherry-pick', '--cherry-mark', '--boundary', '--simplify-by-decoration' + ]), + 'prune': new Set([ + '--dry-run', '-n', '--verbose', '-v', '--no-progress', '--expire' ]) }; + /** + * Enforces command-level safety requirements that a flag allowlist cannot + * express by itself. + * @param {string} command + * @param {string[]} args + * @param {number} commandIndex + * @throws {ProhibitedFlagError} If a command omits a required safety flag + * @private + */ + static _validateCommandSafety(command, args, commandIndex) { + if (command !== 'prune') { + return; + } + const commandArgs = args.slice(commandIndex + 1); + const endOfOptions = commandArgs.indexOf('--'); + const flags = endOfOptions === -1 ? commandArgs : commandArgs.slice(0, endOfOptions); + if (!flags.includes('--dry-run') && !flags.includes('-n')) { + throw new ProhibitedFlagError('prune', 'CommandSanitizer.sanitize', { + message: "The 'prune' command is allowed only with --dry-run or -n" + }); + } + } + /** * Validates command-specific flags against the allowlist. * @param {string} command - The git subcommand (e.g., 'show', 'log') @@ -183,16 +210,31 @@ export default class CommandSanitizer { throw new ValidationError('Arguments array cannot be empty', 'CommandSanitizer.sanitize'); } - // Memory-efficient cache key using a short structural signature - const cacheKey = `${args[0]}:${args.length}:${args[args.length-1]?.length || 0}:${args.join('').length}`; - if (this._cache.has(cacheKey)) { - return args; - } - if (args.length > CommandSanitizer.MAX_ARGS) { throw new ValidationError(`Too many arguments: ${args.length}`, 'CommandSanitizer.sanitize'); } + let totalLength = 0; + for (const arg of args) { + if (typeof arg !== 'string') { + throw new ValidationError('Each argument must be a string', 'CommandSanitizer.sanitize', { arg }); + } + totalLength += arg.length; + if (arg.length > CommandSanitizer.MAX_ARG_LENGTH) { + throw new ValidationError(`Argument too long: ${arg.length}`, 'CommandSanitizer.sanitize'); + } + } + if (totalLength > CommandSanitizer.MAX_TOTAL_LENGTH) { + throw new ValidationError(`Total arguments length too long: ${totalLength}`, 'CommandSanitizer.sanitize'); + } + + // The cache key must preserve every argument. A lossy structural key can + // collide across commands with different safety properties. + const cacheKey = JSON.stringify(args); + if (this._cache.has(cacheKey)) { + return args; + } + // Special case: allow exactly ['--version'] as a global flag check if (args.length === 1 && args[0] === '--version') { return args; @@ -202,9 +244,6 @@ export default class CommandSanitizer { let subcommandIndex = -1; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (typeof arg !== 'string') { - throw new ValidationError('Each argument must be a string', 'CommandSanitizer.sanitize', { arg }); - } if (!arg.startsWith('-')) { subcommandIndex = i; break; @@ -236,19 +275,8 @@ export default class CommandSanitizer { // Validate per-command flag allowlists CommandSanitizer._validateCommandFlags(command, args, subcommandIndex !== -1 ? subcommandIndex : 0); + CommandSanitizer._validateCommandSafety(command, args, subcommandIndex !== -1 ? subcommandIndex : 0); - let totalLength = 0; - for (const arg of args) { - totalLength += arg.length; - if (arg.length > CommandSanitizer.MAX_ARG_LENGTH) { - throw new ValidationError(`Argument too long: ${arg.length}`, 'CommandSanitizer.sanitize'); - } - } - - if (totalLength > CommandSanitizer.MAX_TOTAL_LENGTH) { - throw new ValidationError(`Total arguments length too long: ${totalLength}`, 'CommandSanitizer.sanitize'); - } - // Manage cache size if (this._cache.size >= this._maxCacheSize) { const firstKey = this._cache.keys().next().value; @@ -258,4 +286,4 @@ export default class CommandSanitizer { return args; } -} \ No newline at end of file +} diff --git a/test/PruneInspection.test.js b/test/PruneInspection.test.js new file mode 100644 index 0000000..ee511b3 --- /dev/null +++ b/test/PruneInspection.test.js @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import GitPlumbing from '../index.js'; +import InvalidArgumentError from '../src/domain/errors/InvalidArgumentError.js'; + +describe('prunable-object inspection', () => { + let git; + let repoPath; + + beforeEach(async () => { + repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'git-plumbing-prune-')); + git = await GitPlumbing.createDefault({ cwd: repoPath }); + await git.execute({ args: ['init'] }); + }); + + afterEach(() => { + fs.rmSync(repoPath, { recursive: true, force: true }); + }); + + it('streams dry-run candidates without deleting them', async () => { + const oid = await git.execute({ + args: ['hash-object', '-w', '--stdin'], + input: 'unreachable test object' + }); + const expiresBefore = new Date(Date.now() + 60_000).toISOString(); + const stream = await git.inspectPrunableObjects({ expiresBefore }); + const output = await stream.collect({ asString: true }); + const result = await stream.finished; + + expect(result.code).toBe(0); + expect(output).toContain(`${oid} blob`); + await expect(git.execute({ args: ['cat-file', '-t', oid] })).resolves.toBe('blob'); + }); + + it('constructs the complete non-mutating inspection command', async () => { + let invocation; + const plumbing = new GitPlumbing({ + cwd: repoPath, + runner: async (options) => { + invocation = options; + return { + stdoutStream: new ReadableStream({ + start(controller) { + controller.close(); + } + }), + exitPromise: Promise.resolve({ code: 0, stderr: '' }) + }; + } + }); + const expiresBefore = '2026-07-01T00:00:00.000Z'; + + const stream = await plumbing.inspectPrunableObjects({ expiresBefore }); + await stream.collect(); + + expect(invocation.args).toEqual([ + 'prune', + '--dry-run', + '--verbose', + '--no-progress', + `--expire=${expiresBefore}` + ]); + }); + + for (const expiresBefore of [ + undefined, + 'now', + '2026-07-01', + '2026-13-01T00:00:00.000Z', + '2026-07-01T00:00:00Z' + ]) { + it(`rejects a non-canonical cutoff before execution: ${String(expiresBefore)}`, async () => { + await expect(git.inspectPrunableObjects({ expiresBefore })) + .rejects.toBeInstanceOf(InvalidArgumentError); + }); + } + + it('rejects omitted options with the public validation error', async () => { + await expect(git.inspectPrunableObjects()).rejects.toBeInstanceOf(InvalidArgumentError); + }); +}); diff --git a/test/deno_entry.js b/test/deno_entry.js index e48cf01..a5e3c6e 100644 --- a/test/deno_entry.js +++ b/test/deno_entry.js @@ -6,6 +6,7 @@ import "./GitBlob.test.js"; import "./GitCommitFlow.test.js"; import "./GitRef.test.js"; import "./GitSha.test.js"; +import "./PruneInspection.test.js"; import "./ShellRunner.test.js"; import "./StreamCompletion.test.js"; import "./Streaming.test.js"; diff --git a/test/domain/services/CommandSanitizer.test.js b/test/domain/services/CommandSanitizer.test.js index 001cc2e..71d2bd6 100644 --- a/test/domain/services/CommandSanitizer.test.js +++ b/test/domain/services/CommandSanitizer.test.js @@ -21,6 +21,34 @@ describe('CommandSanitizer', () => { expect(() => sanitizer.sanitize(['show', '--format=%B', '-s', 'HEAD'])).not.toThrow(); }); + it('allows prune only as a dry-run inspection', () => { + expect(() => sanitizer.sanitize([ + 'prune', + '--dry-run', + '--verbose', + '--no-progress', + '--expire=2026-07-01T00:00:00.000Z' + ])).not.toThrow(); + expect(() => sanitizer.sanitize(['prune', '-n', '-v', '--expire=now'])).not.toThrow(); + }); + + it('rejects destructive or unrestricted prune forms', () => { + expect(() => sanitizer.sanitize(['prune', '--verbose'])).toThrow(ProhibitedFlagError); + expect(() => sanitizer.sanitize(['prune', '--dry-run', '--expire=now', '--progress'])) + .toThrow(ProhibitedFlagError); + expect(() => sanitizer.sanitize(['prune', '--', '--dry-run'])) + .toThrow(ProhibitedFlagError); + }); + + it('does not confuse safe and destructive commands in the memoization cache', () => { + sanitizer.sanitize(['prune', '--dry-run']); + expect(() => sanitizer.sanitize(['prune', '--verbose'])).toThrow(ProhibitedFlagError); + }); + + it('validates argument types before constructing a memoization key', () => { + expect(() => sanitizer.sanitize(['rev-parse', 1n])).toThrow(ValidationError); + }); + it('throws ValidationError for unlisted commands', () => { expect(() => sanitizer.sanitize(['push', 'origin', 'main'])).toThrow(ValidationError); }); @@ -211,4 +239,4 @@ describe('CommandSanitizer', () => { expect(() => sanitizer.sanitize(['show', '-p', '--', 'file.txt'])).toThrow(ProhibitedFlagError); }); }); -}); \ No newline at end of file +});