Skip to content

Commit

Permalink
Fix cp and mv (actions#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
Danny McCormick authored Jul 9, 2019
1 parent e85d20f commit d919136
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 116 deletions.
79 changes: 42 additions & 37 deletions packages/io/__tests__/io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,7 @@ describe('cp', () => {
await io.mkdirP(root)
await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'})
await fs.writeFile(targetFile, 'correct content', {encoding: 'utf8'})
let failed = false
try {
await io.cp(sourceFile, targetFile, {recursive: false, force: false})
} catch {
failed = true
}
expect(failed).toBe(true)
await io.cp(sourceFile, targetFile, {recursive: false, force: false})

expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe(
'correct content'
Expand Down Expand Up @@ -132,6 +126,43 @@ describe('cp', () => {
expect(thrown).toBe(true)
await assertNotExists(targetFile)
})

it('Copies symlinks correctly', async () => {
// create the following layout
// sourceFolder
// sourceFolder/nested
// sourceFolder/nested/sourceFile
// sourceFolder/symlinkDirectory -> sourceFile
const root: string = path.join(getTestTemp(), 'cp_with_-r_symlinks')
const sourceFolder: string = path.join(root, 'cp_source')
const nestedFolder: string = path.join(sourceFolder, 'nested')
const sourceFile: string = path.join(nestedFolder, 'cp_source_file')
const symlinkDirectory: string = path.join(sourceFolder, 'symlinkDirectory')

const targetFolder: string = path.join(root, 'cp_target')
const targetFile: string = path.join(
targetFolder,
'nested',
'cp_source_file'
)
const symlinkTargetPath: string = path.join(
targetFolder,
'symlinkDirectory',
'cp_source_file'
)
await io.mkdirP(sourceFolder)
await io.mkdirP(nestedFolder)
await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'})
await createSymlinkDir(nestedFolder, symlinkDirectory)
await io.cp(sourceFolder, targetFolder, {recursive: true})

expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe(
'test file content'
)
expect(await fs.readFile(symlinkTargetPath, {encoding: 'utf8'})).toBe(
'test file content'
)
})
})

describe('mv', () => {
Expand Down Expand Up @@ -189,7 +220,7 @@ describe('mv', () => {
)
})

it('moves directory into existing destination with -r', async () => {
it('moves directory into existing destination', async () => {
const root: string = path.join(getTestTemp(), ' mv_with_-r_existing_dest')
const sourceFolder: string = path.join(root, ' mv_source')
const sourceFile: string = path.join(sourceFolder, ' mv_source_file')
Expand All @@ -203,15 +234,15 @@ describe('mv', () => {
await io.mkdirP(sourceFolder)
await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'})
await io.mkdirP(targetFolder)
await io.mv(sourceFolder, targetFolder, {recursive: true})
await io.mv(sourceFolder, targetFolder)

expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe(
'test file content'
)
await assertNotExists(sourceFile)
})

it('moves directory into non-existing destination with -r', async () => {
it('moves directory into non-existing destination', async () => {
const root: string = path.join(
getTestTemp(),
' mv_with_-r_nonexisting_dest'
Expand All @@ -223,39 +254,13 @@ describe('mv', () => {
const targetFile: string = path.join(targetFolder, ' mv_source_file')
await io.mkdirP(sourceFolder)
await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'})
await io.mv(sourceFolder, targetFolder, {recursive: true})
await io.mv(sourceFolder, targetFolder)

expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe(
'test file content'
)
await assertNotExists(sourceFile)
})

it('tries to move directory without -r', async () => {
const root: string = path.join(getTestTemp(), 'mv_without_-r')
const sourceFolder: string = path.join(root, 'mv_source')
const sourceFile: string = path.join(sourceFolder, 'mv_source_file')

const targetFolder: string = path.join(root, 'mv_target')
const targetFile: string = path.join(
targetFolder,
'mv_source',
'mv_source_file'
)
await io.mkdirP(sourceFolder)
await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'})

let thrown = false
try {
await io.mv(sourceFolder, targetFolder)
} catch (err) {
thrown = true
}

expect(thrown).toBe(true)
await assertExists(sourceFile)
await assertNotExists(targetFile)
})
})

describe('rmRF', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/io/src/io-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import * as fs from 'fs'
import * as path from 'path'

export const {
chmod,
copyFile,
lstat,
mkdir,
readdir,
readlink,
rename,
rmdir,
stat,
symlink,
unlink
} = fs.promises

Expand Down
196 changes: 117 additions & 79 deletions packages/io/src/io.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as childProcess from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
import {promisify} from 'util'
import * as ioUtil from './io-util'
Expand All @@ -16,8 +15,17 @@ export interface CopyOptions {
force?: boolean
}

/**
* Interface for cp/mv options
*/
export interface MoveOptions {
/** Optional. Whether to overwrite existing files in the destination. Defaults to true */
force?: boolean
}

/**
* Copies a file or folder.
* Based off of shelljs - https://github.com/shelljs/shelljs/blob/9237f66c52e5daa40458f94f9565e18e8132f5a6/src/cp.js
*
* @param source source path
* @param dest destination path
Expand All @@ -28,22 +36,73 @@ export async function cp(
dest: string,
options: CopyOptions = {}
): Promise<void> {
await move(source, dest, options, {deleteOriginal: false})
const {force, recursive} = readCopyOptions(options)

const destStat = (await ioUtil.exists(dest)) ? await ioUtil.stat(dest) : null
// Dest is an existing file, but not forcing
if (destStat && destStat.isFile() && !force) {
return
}

// If dest is an existing directory, should copy inside.
const newDest: string =
destStat && destStat.isDirectory()
? path.join(dest, path.basename(source))
: dest

if (!(await ioUtil.exists(source))) {
throw new Error(`no such file or directory: ${source}`)
}
const sourceStat = await ioUtil.stat(source)

if (sourceStat.isDirectory()) {
if (!recursive) {
throw new Error(
`Failed to copy. ${source} is a directory, but tried to copy without recursive flag.`
)
} else {
await cpDirRecursive(source, newDest, 0, force)
}
} else {
if (path.relative(source, newDest) === '') {
// a file cannot be copied to itself
throw new Error(`'${newDest}' and '${source}' are the same file`)
}

await copyFile(source, newDest, force)
}
}

/**
* Moves a path.
*
* @param source source path
* @param dest destination path
* @param options optional. See CopyOptions.
* @param options optional. See MoveOptions.
*/
export async function mv(
source: string,
dest: string,
options: CopyOptions = {}
options: MoveOptions = {}
): Promise<void> {
await move(source, dest, options, {deleteOriginal: true})
if (await ioUtil.exists(dest)) {
let destExists = true
if (await ioUtil.isDirectory(dest)) {
// If dest is directory copy src into dest
dest = path.join(dest, path.basename(source))
destExists = await ioUtil.exists(dest)
}

if (destExists) {
if (options.force == null || options.force) {
await rmRF(dest)
} else {
throw new Error('Destination already exists')
}
}
}
await mkdirP(path.dirname(dest))
await ioUtil.rename(source, dest)
}

/**
Expand Down Expand Up @@ -198,92 +257,71 @@ export async function which(tool: string, check?: boolean): Promise<string> {
}
}

// Copies contents of source into dest, making any necessary folders along the way.
// Deletes the original copy if deleteOriginal is true
async function copyDirectoryContents(
source: string,
dest: string,
force: boolean,
deleteOriginal = false
function readCopyOptions(options: CopyOptions): Required<CopyOptions> {
const force = options.force == null ? true : options.force
const recursive = Boolean(options.recursive)
return {force, recursive}
}

async function cpDirRecursive(
sourceDir: string,
destDir: string,
currentDepth: number,
force: boolean
): Promise<void> {
if (await ioUtil.isDirectory(source)) {
if (await ioUtil.exists(dest)) {
if (!(await ioUtil.isDirectory(dest))) {
throw new Error(`${dest} is not a directory`)
}
} else {
await mkdirP(dest)
}
// Ensure there is not a run away recursive copy
if (currentDepth >= 255) return
currentDepth++

// Copy all child files, and directories recursively
const sourceChildren: string[] = await ioUtil.readdir(source)
await mkdirP(destDir)

for (const newSource of sourceChildren) {
const newDest = path.join(dest, path.basename(newSource))
await copyDirectoryContents(
path.resolve(source, newSource),
newDest,
force,
deleteOriginal
)
}
const files: string[] = await ioUtil.readdir(sourceDir)

if (deleteOriginal) {
await ioUtil.rmdir(source)
}
} else {
if (force) {
await ioUtil.copyFile(source, dest)
for (const fileName of files) {
const srcFile = `${sourceDir}/${fileName}`
const destFile = `${destDir}/${fileName}`
const srcFileStat = await ioUtil.lstat(srcFile)

if (srcFileStat.isDirectory()) {
// Recurse
await cpDirRecursive(srcFile, destFile, currentDepth, force)
} else {
await ioUtil.copyFile(source, dest, fs.constants.COPYFILE_EXCL)
}
if (deleteOriginal) {
await ioUtil.unlink(source)
await copyFile(srcFile, destFile, force)
}
}

// Change the mode for the newly created directory
await ioUtil.chmod(destDir, (await ioUtil.stat(sourceDir)).mode)
}

async function move(
source: string,
dest: string,
options: CopyOptions = {},
moveOptions: {deleteOriginal: boolean}
// Buffered file copy
async function copyFile(
srcFile: string,
destFile: string,
force: boolean
): Promise<void> {
const {force, recursive} = readCopyOptions(options)

if (await ioUtil.isDirectory(source)) {
if (!recursive) {
throw new Error(`non-recursive cp failed, ${source} is a directory`)
}

// If directory exists, move source inside it. Otherwise, create it and move contents of source inside.
if (await ioUtil.exists(dest)) {
if (!(await ioUtil.isDirectory(dest))) {
throw new Error(`${dest} is not a directory`)
if ((await ioUtil.lstat(srcFile)).isSymbolicLink()) {
// unlink/re-link it
try {
await ioUtil.lstat(destFile)
await ioUtil.unlink(destFile)
} catch (e) {
// Try to override file permission
if (e.code === 'EPERM') {
await ioUtil.chmod(destFile, '0666')
await ioUtil.unlink(destFile)
}

dest = path.join(dest, path.basename(source))
}

await copyDirectoryContents(source, dest, force, moveOptions.deleteOriginal)
} else {
if ((await ioUtil.exists(dest)) && (await ioUtil.isDirectory(dest))) {
dest = path.join(dest, path.basename(source))
}
if (force) {
await ioUtil.copyFile(source, dest)
} else {
await ioUtil.copyFile(source, dest, fs.constants.COPYFILE_EXCL)
// other errors = it doesn't exist, no work to do
}

if (moveOptions.deleteOriginal) {
await ioUtil.unlink(source)
}
// Copy over symlink
const symlinkFull: string = await ioUtil.readlink(srcFile)
await ioUtil.symlink(
symlinkFull,
destFile,
ioUtil.IS_WINDOWS ? 'junction' : null
)
} else if (!(await ioUtil.exists(destFile)) || force) {
await ioUtil.copyFile(srcFile, destFile)
}
}

function readCopyOptions(options: CopyOptions): Required<CopyOptions> {
const force = options.force == null ? true : options.force
const recursive = Boolean(options.recursive)
return {force, recursive}
}

0 comments on commit d919136

Please sign in to comment.