Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## [3.0.3] - 2026-05-06

### Fixed
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<object-id> <type>` records incrementally.
}
await stream.finished;
```

## 📖 Deep Dives

- [**Standard Guide**](./GUIDE.md) - From zero to atomic commits.
Expand Down
36 changes: 36 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<GitStream>}
*/
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
Expand Down
74 changes: 51 additions & 23 deletions src/domain/services/CommandSanitizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export default class CommandSanitizer {
'init',
'config',
'log',
'show'
'show',
'prune'
]);

/**
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -258,4 +286,4 @@ export default class CommandSanitizer {

return args;
}
}
}
52 changes: 52 additions & 0 deletions test/PruneInspection.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions test/deno_entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
29 changes: 28 additions & 1 deletion test/domain/services/CommandSanitizer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -211,4 +238,4 @@ describe('CommandSanitizer', () => {
expect(() => sanitizer.sanitize(['show', '-p', '--', 'file.txt'])).toThrow(ProhibitedFlagError);
});
});
});
});
Loading