feat(smartfs.directory): feat(smartfs.directory): add directory copy/move with conflict handling and options
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-16 - 1.3.0 - feat(smartfs.directory)
|
||||||
|
feat(smartfs.directory): add directory copy/move with conflict handling and options
|
||||||
|
|
||||||
|
- Implement Directory.copy(targetPath) and Directory.move(targetPath) with provider-backed file operations (createDirectory, listDirectory, copyFile, deleteDirectory).
|
||||||
|
- Add new directory options and fluent setters: applyFilter, overwrite, preserveTimestamps, onConflict (defaults: applyFilter=true, overwrite=false, preserveTimestamps=false, onConflict='merge').
|
||||||
|
- Copy supports recursive listing, optional filtering (applyFilter), overwrite behavior and timestamp preservation; onConflict supports 'merge'|'error'|'replace'. Move performs copy then deletes the source.
|
||||||
|
- Add comprehensive tests for copy/move: basic copy, recursive copy, filter-based copy, applyFilter(false) behavior, overwrite handling, onConflict error/replace cases, move semantics, and copying empty directories.
|
||||||
|
- Update npmextra.json to use scoped keys (@git.zone/cli, @ship.zone/szci) and add release registry/access configuration.
|
||||||
|
|
||||||
## 2025-12-02 - 1.2.0 - feat(smartfs.directory)
|
## 2025-12-02 - 1.2.0 - feat(smartfs.directory)
|
||||||
Add directory treeHash: deterministic content-based hashing of directory trees with streaming and algorithm option
|
Add directory treeHash: deterministic content-based hashing of directory trees with streaming and algorithm option
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"gitzone": {
|
"@git.zone/cli": {
|
||||||
"projectType": "npm",
|
"projectType": "npm",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
@@ -9,10 +9,16 @@
|
|||||||
"npmPackagename": "@push.rocks/smartfs",
|
"npmPackagename": "@push.rocks/smartfs",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "push.rocks"
|
"projectDomain": "push.rocks"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": []
|
||||||
"npmAccessLevel": "public"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,6 +367,189 @@ tap.test('treeHash of empty directory should return consistent hash', async () =
|
|||||||
expect(hash1.length).toEqual(64);
|
expect(hash1.length).toEqual(64);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Directory copy/move tests ---
|
||||||
|
|
||||||
|
tap.test('should copy a directory', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-dir-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-dir-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file1.txt')).write('content1');
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file2.txt')).write('content2');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).copy(destPath);
|
||||||
|
|
||||||
|
// Source should still exist
|
||||||
|
const sourceExists = await smartFs.directory(sourcePath).exists();
|
||||||
|
expect(sourceExists).toEqual(true);
|
||||||
|
|
||||||
|
// Destination should exist with same files
|
||||||
|
const destExists = await smartFs.directory(destPath).exists();
|
||||||
|
expect(destExists).toEqual(true);
|
||||||
|
|
||||||
|
const destContent1 = await smartFs.file(path.join(destPath, 'file1.txt')).encoding('utf8').read();
|
||||||
|
const destContent2 = await smartFs.file(path.join(destPath, 'file2.txt')).encoding('utf8').read();
|
||||||
|
|
||||||
|
expect(destContent1).toEqual('content1');
|
||||||
|
expect(destContent2).toEqual('content2');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should copy a directory recursively with nested subdirectories', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-recursive-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-recursive-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'root.txt')).write('root');
|
||||||
|
await smartFs.directory(path.join(sourcePath, 'sub1')).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'sub1', 'nested1.txt')).write('nested1');
|
||||||
|
await smartFs.directory(path.join(sourcePath, 'sub1', 'sub2')).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'sub1', 'sub2', 'deep.txt')).write('deep');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).copy(destPath);
|
||||||
|
|
||||||
|
// Verify all files copied
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'root.txt')).exists()).toEqual(true);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'sub1', 'nested1.txt')).exists()).toEqual(true);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'sub1', 'sub2', 'deep.txt')).exists()).toEqual(true);
|
||||||
|
|
||||||
|
const deepContent = await smartFs.file(path.join(destPath, 'sub1', 'sub2', 'deep.txt')).encoding('utf8').read();
|
||||||
|
expect(deepContent).toEqual('deep');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should copy directory with filter applied', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-filter-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-filter-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.ts')).write('typescript');
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.js')).write('javascript');
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.txt')).write('text');
|
||||||
|
|
||||||
|
// Copy only .ts files
|
||||||
|
await smartFs.directory(sourcePath).filter(/\.ts$/).copy(destPath);
|
||||||
|
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'file.ts')).exists()).toEqual(true);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'file.js')).exists()).toEqual(false);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'file.txt')).exists()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should copy all files when applyFilter(false)', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-no-filter-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-no-filter-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.ts')).write('typescript');
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.js')).write('javascript');
|
||||||
|
|
||||||
|
// Filter is set but applyFilter(false) ignores it
|
||||||
|
await smartFs.directory(sourcePath).filter(/\.ts$/).applyFilter(false).copy(destPath);
|
||||||
|
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'file.ts')).exists()).toEqual(true);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'file.js')).exists()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should copy with overwrite(true) replacing existing files', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-overwrite-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-overwrite-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.txt')).write('new content');
|
||||||
|
|
||||||
|
await smartFs.directory(destPath).create();
|
||||||
|
await smartFs.file(path.join(destPath, 'file.txt')).write('old content');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).overwrite(true).copy(destPath);
|
||||||
|
|
||||||
|
const content = await smartFs.file(path.join(destPath, 'file.txt')).encoding('utf8').read();
|
||||||
|
expect(content).toEqual('new content');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should throw error when onConflict is error and target exists', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-conflict-error-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-conflict-error-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file.txt')).write('content');
|
||||||
|
await smartFs.directory(destPath).create();
|
||||||
|
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await smartFs.directory(sourcePath).onConflict('error').copy(destPath);
|
||||||
|
} catch (e: any) {
|
||||||
|
threw = true;
|
||||||
|
expect(e.message).toInclude('EEXIST');
|
||||||
|
}
|
||||||
|
expect(threw).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should replace target when onConflict is replace', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-replace-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-replace-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'new.txt')).write('new');
|
||||||
|
|
||||||
|
await smartFs.directory(destPath).create();
|
||||||
|
await smartFs.file(path.join(destPath, 'old.txt')).write('old');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).onConflict('replace').copy(destPath);
|
||||||
|
|
||||||
|
// Old file should be gone, new file should exist
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'old.txt')).exists()).toEqual(false);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'new.txt')).exists()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should move a directory', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'move-dir-source');
|
||||||
|
const destPath = path.join(tempDir, 'move-dir-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file1.txt')).write('content1');
|
||||||
|
await smartFs.file(path.join(sourcePath, 'file2.txt')).write('content2');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).move(destPath);
|
||||||
|
|
||||||
|
// Source should no longer exist
|
||||||
|
const sourceExists = await smartFs.directory(sourcePath).exists();
|
||||||
|
expect(sourceExists).toEqual(false);
|
||||||
|
|
||||||
|
// Destination should exist with files
|
||||||
|
const destExists = await smartFs.directory(destPath).exists();
|
||||||
|
expect(destExists).toEqual(true);
|
||||||
|
|
||||||
|
const destContent1 = await smartFs.file(path.join(destPath, 'file1.txt')).encoding('utf8').read();
|
||||||
|
expect(destContent1).toEqual('content1');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should move directory recursively', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'move-recursive-source');
|
||||||
|
const destPath = path.join(tempDir, 'move-recursive-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'root.txt')).write('root');
|
||||||
|
await smartFs.directory(path.join(sourcePath, 'sub')).create();
|
||||||
|
await smartFs.file(path.join(sourcePath, 'sub', 'nested.txt')).write('nested');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).move(destPath);
|
||||||
|
|
||||||
|
// Source should not exist
|
||||||
|
expect(await smartFs.directory(sourcePath).exists()).toEqual(false);
|
||||||
|
|
||||||
|
// All files should be at destination
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'root.txt')).exists()).toEqual(true);
|
||||||
|
expect(await smartFs.file(path.join(destPath, 'sub', 'nested.txt')).exists()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should copy empty directory', async () => {
|
||||||
|
const sourcePath = path.join(tempDir, 'copy-empty-source');
|
||||||
|
const destPath = path.join(tempDir, 'copy-empty-dest');
|
||||||
|
|
||||||
|
await smartFs.directory(sourcePath).create();
|
||||||
|
await smartFs.directory(sourcePath).copy(destPath);
|
||||||
|
|
||||||
|
expect(await smartFs.directory(destPath).exists()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('cleanup temp directory', async () => {
|
tap.test('cleanup temp directory', async () => {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
expect(true).toEqual(true);
|
expect(true).toEqual(true);
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartfs',
|
name: '@push.rocks/smartfs',
|
||||||
version: '1.2.0',
|
version: '1.3.0',
|
||||||
description: 'a cross platform extendable fs module'
|
description: 'a cross platform extendable fs module'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,18 @@ export class SmartFsDirectory {
|
|||||||
mode?: TFileMode;
|
mode?: TFileMode;
|
||||||
filter?: string | RegExp | ((entry: IDirectoryEntry) => boolean);
|
filter?: string | RegExp | ((entry: IDirectoryEntry) => boolean);
|
||||||
includeStats?: boolean;
|
includeStats?: boolean;
|
||||||
} = {};
|
// Copy/move options
|
||||||
|
applyFilter?: boolean;
|
||||||
|
overwrite?: boolean;
|
||||||
|
preserveTimestamps?: boolean;
|
||||||
|
onConflict?: 'merge' | 'error' | 'replace';
|
||||||
|
} = {
|
||||||
|
// Defaults for copy/move
|
||||||
|
applyFilter: true,
|
||||||
|
overwrite: false,
|
||||||
|
preserveTimestamps: false,
|
||||||
|
onConflict: 'merge',
|
||||||
|
};
|
||||||
|
|
||||||
constructor(provider: ISmartFsProvider, path: string) {
|
constructor(provider: ISmartFsProvider, path: string) {
|
||||||
this.provider = provider;
|
this.provider = provider;
|
||||||
@@ -82,6 +93,46 @@ export class SmartFsDirectory {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control whether filter() is applied during copy/move operations
|
||||||
|
* @param apply - If true, only copy/move files matching filter; if false, copy/move all files
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
public applyFilter(apply: boolean = true): this {
|
||||||
|
this.options.applyFilter = apply;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control whether to overwrite existing files during copy/move
|
||||||
|
* @param overwrite - If true, overwrite existing files; if false, throw error on conflict
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
public overwrite(overwrite: boolean = true): this {
|
||||||
|
this.options.overwrite = overwrite;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control whether to preserve file timestamps during copy/move
|
||||||
|
* @param preserve - If true, preserve original timestamps; if false, use current time
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
public preserveTimestamps(preserve: boolean = true): this {
|
||||||
|
this.options.preserveTimestamps = preserve;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control behavior when target directory already exists
|
||||||
|
* @param behavior - 'merge' to merge contents, 'error' to throw, 'replace' to delete and recreate
|
||||||
|
* @default 'merge'
|
||||||
|
*/
|
||||||
|
public onConflict(behavior: 'merge' | 'error' | 'replace'): this {
|
||||||
|
this.options.onConflict = behavior;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Action Methods (return Promises) ---
|
// --- Action Methods (return Promises) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,6 +183,102 @@ export class SmartFsDirectory {
|
|||||||
return this.provider.directoryStat(this.path);
|
return this.provider.directoryStat(this.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the directory to a new location
|
||||||
|
* @param targetPath - Destination path
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Basic copy
|
||||||
|
* await fs.directory('/source').copy('/target');
|
||||||
|
*
|
||||||
|
* // Copy with options
|
||||||
|
* await fs.directory('/source')
|
||||||
|
* .recursive()
|
||||||
|
* .filter('*.ts')
|
||||||
|
* .overwrite(true)
|
||||||
|
* .preserveTimestamps(true)
|
||||||
|
* .copy('/target');
|
||||||
|
*
|
||||||
|
* // Copy all files (ignore filter)
|
||||||
|
* await fs.directory('/source')
|
||||||
|
* .applyFilter(false)
|
||||||
|
* .copy('/target');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public async copy(targetPath: string): Promise<void> {
|
||||||
|
const normalizedTarget = this.provider.normalizePath(targetPath);
|
||||||
|
|
||||||
|
// Handle conflict behavior
|
||||||
|
const targetExists = await this.provider.directoryExists(normalizedTarget);
|
||||||
|
if (targetExists) {
|
||||||
|
if (this.options.onConflict === 'error') {
|
||||||
|
throw new Error(`EEXIST: directory already exists: ${normalizedTarget}`);
|
||||||
|
}
|
||||||
|
if (this.options.onConflict === 'replace') {
|
||||||
|
await this.provider.deleteDirectory(normalizedTarget, { recursive: true });
|
||||||
|
}
|
||||||
|
// 'merge' (default) - continue and overwrite based on file settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create target directory
|
||||||
|
await this.provider.createDirectory(normalizedTarget, { recursive: true });
|
||||||
|
|
||||||
|
// List entries (always recursive for copy, respects filter based on applyFilter option)
|
||||||
|
const listOptions: IListOptions = {
|
||||||
|
recursive: true,
|
||||||
|
filter: this.options.applyFilter ? this.options.filter : undefined,
|
||||||
|
includeStats: false,
|
||||||
|
};
|
||||||
|
const entries = await this.provider.listDirectory(this.path, listOptions);
|
||||||
|
|
||||||
|
// Process entries - sort to ensure directories are created before their contents
|
||||||
|
const sortedEntries = entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
|
||||||
|
for (const entry of sortedEntries) {
|
||||||
|
const relativePath = entry.path.substring(this.path.length);
|
||||||
|
const targetEntryPath = this.provider.joinPath(normalizedTarget, relativePath);
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
await this.provider.createDirectory(targetEntryPath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const parentPath = targetEntryPath.substring(0, targetEntryPath.lastIndexOf('/'));
|
||||||
|
if (parentPath && parentPath !== normalizedTarget) {
|
||||||
|
await this.provider.createDirectory(parentPath, { recursive: true });
|
||||||
|
}
|
||||||
|
// Copy file using provider
|
||||||
|
await this.provider.copyFile(entry.path, targetEntryPath, {
|
||||||
|
preserveTimestamps: this.options.preserveTimestamps,
|
||||||
|
overwrite: this.options.overwrite,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the directory to a new location
|
||||||
|
* @param targetPath - Destination path
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Basic move
|
||||||
|
* await fs.directory('/source').move('/target');
|
||||||
|
*
|
||||||
|
* // Move with conflict handling
|
||||||
|
* await fs.directory('/source')
|
||||||
|
* .onConflict('replace')
|
||||||
|
* .move('/target');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public async move(targetPath: string): Promise<void> {
|
||||||
|
// Copy first using current configuration
|
||||||
|
await this.copy(targetPath);
|
||||||
|
|
||||||
|
// Delete source (always recursive, regardless of filter - this matches mv behavior)
|
||||||
|
await this.provider.deleteDirectory(this.path, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the directory path
|
* Get the directory path
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user