Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
77 changes: 73 additions & 4 deletions apps/cli/src/commands/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { randomUUID } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline/promises'
import type { DiskPushStore } from '@diskpush/database'
import { capabilityCacheKey } from '../resolve.js'
Expand All @@ -15,7 +18,7 @@ import { summarizeChanges, topologyOf } from '@diskpush/schemas'
import { EXIT } from '../exit-codes.js'
import { estimateRemaining, formatBytes, formatDuration, formatRate, pluralize, table } from '../format.js'
import { failure, type Output } from '../output.js'
import { flagValue, hasFlag, type ParsedArgv } from '../parse-argv.js'
import { flagValue, flagValues, hasFlag, type ParsedArgv } from '../parse-argv.js'
import { detectLocalCapabilities, optionsFromFlags, resolveEndpoint } from '../resolve.js'
import type { RsyncCapabilities } from '@diskpush/rsync-core'

Expand Down Expand Up @@ -61,6 +64,39 @@ export async function runTransfer(

if (alias.deleteMode !== 'off') options.deleteMode = alias.deleteMode

/*
* `--only NAME` narrows the transfer to entries inside the source directory,
* the CLI's version of ticking rows in the desktop's pane. It becomes an
* rsync `--files-from` list, which is why the names are checked here: a
* separator or a `..` would silently widen the transfer to somewhere the
* user did not name.
*/
const only = flagValues(parsed, '--only')
let selectionCleanup: (() => void) | null = null
if (only.length > 0) {
const bad = only.find(
(name) => name.includes('/') || name.includes('\\') || name.includes('\0') || name === '.' || name === '..',
)
if (bad !== undefined) {
return failure(
output,
`--only takes a name inside the source directory, not a path: ${JSON.stringify(bad)}.`,
EXIT.usage,
)
}
const directory = mkdtempSync(join(tmpdir(), 'diskpush-only-'))
const listPath = join(directory, 'files-from')
// NUL-separated: a newline is legal in a filename, and a line-separated
// list would split one such name into two paths that do not exist.
writeFileSync(listPath, `${only.join('\0')}\0`)
options = { ...options, filesFrom: listPath, from0: true }
selectionCleanup = () => rmSync(directory, { recursive: true, force: true })
// Registered rather than called at each of this function's many returns:
// rsync has read the list long before the process ends, and one handler
// cannot be forgotten the way eight call sites can.
process.once('exit', selectionCleanup)
}

const source = await resolveEndpoint(store, sourceInput)
const destination = await resolveEndpoint(store, destinationInput)
const topology = topologyOf(source.endpoint, destination.endpoint)
Expand Down Expand Up @@ -100,9 +136,20 @@ export async function runTransfer(
}

// --- preview ------------------------------------------------------------
// Mirror always previews before it can run. A plain sync previews only when
// asked, because its dry run costs a full scan for no safety benefit.
const wantsPreview = options.deleteMode !== 'off' || options.dryRun
/*
* Everything previews, not only a mirror.
*
* This used to skip the dry run for a plain sync, on the reasoning that it
* "costs a full scan for no safety benefit". Deleting is not the only way to
* regret a transfer: two named folders can turn into forty thousand files,
* and by the time anything is on screen it is already copying. A scan is
* cheap next to that.
*
* `--yes` still skips the question, which is what a script passes; the scan
* itself is skipped only by `--no-preview`, for someone who genuinely wants
* the old behaviour.
*/
const wantsPreview = !hasFlag(parsed, '--no-preview')
let preview: Awaited<ReturnType<typeof runPreview>> | null = null

if (wantsPreview) {
Expand Down Expand Up @@ -147,6 +194,28 @@ export async function runTransfer(
}
}

/*
* A plain sync is approved too, but only where there is somebody to ask.
*
* A script that pipes us, passes --non-interactive, or passes --yes has
* already decided, and turning those into a refusal would break every
* scheduled profile run. The prompt is for the interactive case, which is
* the one where a surprise is possible.
*/
if (options.deleteMode === 'off' && preview) {
const moving = preview.changes.filter((c) => c.action === 'add' || c.action === 'update').length
const asking = !hasFlag(parsed, '--yes') && !hasFlag(parsed, '--non-interactive') && process.stdin.isTTY

if (moving === 0) {
return finish(output, EXIT.ok, 'Nothing to transfer. The destination already matches.', {
changes: summarizeChanges(preview.changes),
})
}
if (asking && !(await confirm(`${alias.label} ${pluralize(moving, 'file')}?`))) {
return failure(output, `${alias.label} cancelled. Nothing was transferred.`, EXIT.refused)
}
}

// --- run -----------------------------------------------------------------
let plan: ExecutionPlan
try {
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/parse-argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,28 @@ describe('commands', () => {
expect(parsed.positionals).toEqual(['sync', './b/'])
})
})

describe('--only', () => {
/*
* The bug: `--only` was not in VALUE_FLAGS, so it consumed nothing. Each
* name fell through to the positionals, `flagValues` came back empty, and
* `diskpush sync SRC DST --only movieA --only movieB` synced the whole
* directory anyway. Which is the exact bug --only exists to fix.
*/
it('takes a value, and repeats', () => {
const parsed = parseArgv(['sync', 'dev:/srv/', './out/', '--only', 'movieA', '--only', 'movieB'])
expect(flagValues(parsed, '--only')).toEqual(['movieA', 'movieB'])
// The names are flag values, not endpoints.
expect(parsed.positionals).toEqual(['dev:/srv/', './out/'])
})

it('keeps a name with spaces in one piece', () => {
const parsed = parseArgv(['sync', 'dev:/srv/', './out/', '--only', 'The Movie (2019)'])
expect(flagValues(parsed, '--only')).toEqual(['The Movie (2019)'])
})

it('accepts the --only=NAME form too', () => {
const parsed = parseArgv(['sync', 'dev:/srv/', './out/', '--only=movieA'])
expect(flagValues(parsed, '--only')).toEqual(['movieA'])
})
})
2 changes: 2 additions & 0 deletions apps/cli/src/parse-argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const VALUE_FLAGS = new Set([
'--exclude-from',
'--include-from',
'--files-from',
// Repeatable: one entry name inside the source directory, per occurrence.
'--only',
'--bwlimit',
'--max-size',
'--min-size',
Expand Down
Loading
Loading