From 8519a846e5b57d94abfda413b060a10749eaab3b Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 13 Jul 2026 10:27:30 -0700 Subject: [PATCH 1/3] feat: add safe prune inspection --- CHANGELOG.md | 10 +++ README.md | 18 +++++ index.js | 36 +++++++++ src/domain/services/CommandSanitizer.js | 74 +++++++++++++------ test/PruneInspection.test.js | 52 +++++++++++++ test/deno_entry.js | 1 + test/domain/services/CommandSanitizer.test.js | 29 +++++++- 7 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 test/PruneInspection.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd8827..7921546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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..867ddf3 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`; 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..f98e098 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', `--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..31d34e1 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', '--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..05ac130 --- /dev/null +++ b/test/PruneInspection.test.js @@ -0,0 +1,52 @@ +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'); + }); + + 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..221ad4b 100644 --- a/test/domain/services/CommandSanitizer.test.js +++ b/test/domain/services/CommandSanitizer.test.js @@ -21,6 +21,33 @@ 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', + '--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 +238,4 @@ describe('CommandSanitizer', () => { expect(() => sanitizer.sanitize(['show', '-p', '--', 'file.txt'])).toThrow(ProhibitedFlagError); }); }); -}); \ No newline at end of file +}); From b0fbde295b36a0fd9fa7b58fbd3fbfa6b09d07f2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 13 Jul 2026 10:41:07 -0700 Subject: [PATCH 2/3] fix: honor prune inspection command contract --- README.md | 4 +-- index.js | 2 +- src/domain/services/CommandSanitizer.js | 2 +- test/PruneInspection.test.js | 30 +++++++++++++++++++ test/domain/services/CommandSanitizer.test.js | 1 + 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 867ddf3..4978fa2 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ 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`; unrestricted `git prune` -remains prohibited by the command sanitizer. +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' }); diff --git a/index.js b/index.js index f98e098..2b36d15 100644 --- a/index.js +++ b/index.js @@ -231,7 +231,7 @@ export default class GitPlumbing { async inspectPrunableObjects({ expiresBefore } = {}) { const expiry = normalizePruneExpiry(expiresBefore); return await this.executeStream({ - args: ['prune', '--dry-run', '--verbose', `--expire=${expiry}`] + args: ['prune', '--dry-run', '--verbose', '--no-progress', `--expire=${expiry}`] }); } diff --git a/src/domain/services/CommandSanitizer.js b/src/domain/services/CommandSanitizer.js index 31d34e1..ff8903d 100644 --- a/src/domain/services/CommandSanitizer.js +++ b/src/domain/services/CommandSanitizer.js @@ -101,7 +101,7 @@ export default class CommandSanitizer { '--boundary', '--simplify-by-decoration' ]), 'prune': new Set([ - '--dry-run', '-n', '--verbose', '-v', '--expire' + '--dry-run', '-n', '--verbose', '-v', '--no-progress', '--expire' ]) }; diff --git a/test/PruneInspection.test.js b/test/PruneInspection.test.js index 05ac130..ee511b3 100644 --- a/test/PruneInspection.test.js +++ b/test/PruneInspection.test.js @@ -33,6 +33,36 @@ describe('prunable-object inspection', () => { 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', diff --git a/test/domain/services/CommandSanitizer.test.js b/test/domain/services/CommandSanitizer.test.js index 221ad4b..71d2bd6 100644 --- a/test/domain/services/CommandSanitizer.test.js +++ b/test/domain/services/CommandSanitizer.test.js @@ -26,6 +26,7 @@ describe('CommandSanitizer', () => { '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(); From 4fe6575a2d85a50e7d4f955a185c2e0016dcee12 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 13 Jul 2026 10:43:42 -0700 Subject: [PATCH 3/3] docs: fix changelog heading spacing --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7921546..7b3f591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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.