added wildcard into --skip switch
This commit is contained in:
@ -18,6 +18,11 @@
|
||||
- `2` argument/usage error
|
||||
- Missing `--host`, `--user`, `--password` should still support interactive prompt mode.
|
||||
- `--skip` and `--skip-delete` matching semantics should remain stable.
|
||||
- Rules without wildcard characters (`*`, `?`) use legacy exact matching.
|
||||
- Exact rules without slash match any path segment; exact rules with slash match a relative subpath.
|
||||
- Rules containing `*` or `?` are glob patterns matched against normalized relative paths.
|
||||
- Glob matching should prefer native `fnmatch()` and keep a regex fallback for platforms where it is unavailable.
|
||||
- Skip patterns should be prepared once, not recompiled for every file.
|
||||
- `--no-print-skip` must suppress only `SKIP` log lines, without changing skip decisions or summary counters.
|
||||
- `--delete-dir` safety guard against dangerous paths (`/`, empty path, dot paths) must remain intact.
|
||||
|
||||
|
||||
22
README.md
22
README.md
@ -48,8 +48,8 @@ php src/SFTPsync.php --host <host> --user <user> --password <password> [--port <
|
||||
- `--port <port>`: optional, default `22`
|
||||
- `--print-relative`: show paths relative to action root in logs
|
||||
- `--no-print-skip`: suppress `SKIP` status lines during execution
|
||||
- `--skip <file_or_dir>`: repeatable, applied to `--sync` and `--sync-down`
|
||||
- `--skip-delete <file_or_dir>`: repeatable, applied to `--delete` and `--delete-dir`
|
||||
- `--skip <pattern>`: repeatable, exact names/paths or glob patterns (`*`, `?`), applied to `--sync` and `--sync-down`
|
||||
- `--skip-delete <pattern>`: repeatable, exact names/paths or glob patterns (`*`, `?`), applied to `--delete` and `--delete-dir`
|
||||
- `-h`, `--help`: show help
|
||||
|
||||
## Examples
|
||||
@ -69,7 +69,7 @@ php src/SFTPsync.php --host example.com --user u --password p \
|
||||
|
||||
# Skip selected entries during sync
|
||||
php src/SFTPsync.php --host example.com --user u --password p \
|
||||
--skip .git --skip node_modules --skip cache/tmp \
|
||||
--skip .git --skip node_modules --skip "*.log" --skip "cache/*" \
|
||||
--sync ./app /srv/app
|
||||
|
||||
# Delete remote directory but keep selected subpaths
|
||||
@ -90,10 +90,22 @@ After upload/download, mtime is propagated to the target when possible.
|
||||
|
||||
## Skip Rule Matching
|
||||
|
||||
- Rule without slash (example: `node_modules`) matches any path segment with that name.
|
||||
- Rule with slash (example: `cache/tmp`) matches that subpath within a relative path.
|
||||
- Rule without wildcard characters (example: `node_modules`) keeps exact matching.
|
||||
- Exact rule without slash (example: `node_modules`) matches any path segment with that name.
|
||||
- Exact rule with slash (example: `cache/tmp`) matches that subpath within a relative path.
|
||||
- Rule containing `*` or `?` is treated as a glob pattern. `*` matches any characters, and `?` matches one character.
|
||||
- Glob rule without slash (example: `*.log`) can match file or directory names at any depth.
|
||||
- Glob rule with slash (example: `src/temp/*.log` or `cache/*`) is matched against relative paths.
|
||||
- Rules are normalized to forward slashes.
|
||||
|
||||
Examples:
|
||||
|
||||
- `--skip=*.bat` skips `test.bat` and `tools/deploy.bat`, but not `test.bat.txt`.
|
||||
- `--skip=*.log` skips `app.log` and `src/temp/test.log`, but not `app.log.1`.
|
||||
- `--skip=backup-*` skips `backup-2025`, `backup-old`, and `backup-test`.
|
||||
- `--skip=cache/*` skips content under `cache`.
|
||||
- `--skip=node_modules` and `--skip=.git` keep the original exact-name behavior.
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- `--delete-dir` refuses dangerous roots such as empty path, `/`, `.`, `..`, and similar dot paths.
|
||||
|
||||
126
src/SFTPsync.php
126
src/SFTPsync.php
@ -150,7 +150,7 @@ final class SftpAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,no_print_skip:bool,skip:list<string>,skip_delete:list<string>,actions:list<array<string,string>>}
|
||||
* @return array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,no_print_skip:bool,skip:list<array<string,mixed>>,skip_delete:list<array<string,mixed>>,actions:list<array<string,string>>}
|
||||
*/
|
||||
function parseArguments(array $argv): array
|
||||
{
|
||||
@ -210,13 +210,13 @@ function parseArguments(array $argv): array
|
||||
break;
|
||||
|
||||
case '--skip':
|
||||
$skipValue = readCliValue($argv, ++$i, '--skip <file_or_dir>');
|
||||
$parsed['skip'][] = normalizeSkipRule($skipValue);
|
||||
$skipValue = readCliValue($argv, ++$i, '--skip <pattern>');
|
||||
$parsed['skip'][] = prepareSkipRule($skipValue);
|
||||
break;
|
||||
|
||||
case '--skip-delete':
|
||||
$skipDeleteValue = readCliValue($argv, ++$i, '--skip-delete <file_or_dir>');
|
||||
$parsed['skip_delete'][] = normalizeSkipRule($skipDeleteValue);
|
||||
$skipDeleteValue = readCliValue($argv, ++$i, '--skip-delete <pattern>');
|
||||
$parsed['skip_delete'][] = prepareSkipRule($skipDeleteValue);
|
||||
break;
|
||||
|
||||
case '--sync':
|
||||
@ -312,8 +312,8 @@ Options:
|
||||
--port <port> Optional, default 22
|
||||
--print-relative Show logged paths relative to action local/remote roots
|
||||
--no-print-skip Do not print SKIP logs during synchronization
|
||||
--skip <file_or_dir> Repeatable, skip matching names/paths in --sync and --sync-down
|
||||
--skip-delete <file_or_dir> Repeatable, skip matching names/paths in --delete and --delete-dir
|
||||
--skip <pattern> Repeatable, skip exact names/paths or glob patterns (*, ?) in --sync and --sync-down
|
||||
--skip-delete <pattern> Repeatable, skip exact names/paths or glob patterns (*, ?) in --delete and --delete-dir
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
@ -321,7 +321,7 @@ Examples:
|
||||
php SFTPsync.php --host example.com --user u --password p --sync-down /var/backups ./backups
|
||||
php SFTPsync.php --host example.com --user u --password p --sync ./a /remote/a --delete /remote/a/old.zip --sync-down /remote/logs ./logs
|
||||
php SFTPsync.php --host example.com --user u --password p --delete-dir /tmp/testdir
|
||||
php SFTPsync.php --host example.com --user u --password p --skip .git --skip node_modules --sync ./app /srv/app --skip-delete .well-known --delete-dir /srv/app
|
||||
php SFTPsync.php --host example.com --user u --password p --skip .git --skip node_modules --skip "*.log" --sync ./app /srv/app --skip-delete .well-known --delete-dir /srv/app
|
||||
php SFTPsync.php --host example.com --user u --password p --print-relative --sync ./local /var/www
|
||||
|
||||
Exit codes:
|
||||
@ -813,38 +813,118 @@ function normalizeSkipRule(string $rule): string
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip rules without wildcard characters keep the legacy exact matching:
|
||||
* a plain name matches any path segment, and a path matches that relative subpath.
|
||||
* Rules containing "*" or "?" are glob patterns matched against normalized relative paths.
|
||||
*
|
||||
* @return array{pattern:string,is_glob:bool,has_slash:bool,regex:?string}
|
||||
*/
|
||||
function prepareSkipRule(string $rule): array
|
||||
{
|
||||
$pattern = normalizeSkipRule($rule);
|
||||
$isGlob = str_contains($pattern, '*') || str_contains($pattern, '?');
|
||||
|
||||
return [
|
||||
'pattern' => $pattern,
|
||||
'is_glob' => $isGlob,
|
||||
'has_slash' => str_contains($pattern, '/'),
|
||||
'regex' => $isGlob && !function_exists('fnmatch') ? globPatternToRegex($pattern) : null,
|
||||
];
|
||||
}
|
||||
|
||||
function isPathSkipped(string $path, array $skipRules): bool
|
||||
{
|
||||
if ($skipRules === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($skipRules as $rule) {
|
||||
$preparedRule = is_array($rule) ? $rule : prepareSkipRule((string)$rule);
|
||||
if (matchesSkipPattern($path, $preparedRule)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{pattern:string,is_glob:bool,has_slash:bool,regex:?string} $rule
|
||||
*/
|
||||
function matchesSkipPattern(string $path, array $rule): bool
|
||||
{
|
||||
$normalizedPath = trim(str_replace('\\', '/', $path), '/');
|
||||
if ($normalizedPath === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pattern = $rule['pattern'];
|
||||
$segments = explode('/', $normalizedPath);
|
||||
$pathWithGuards = '/' . $normalizedPath . '/';
|
||||
foreach ($skipRules as $rule) {
|
||||
$normalizedRule = trim(str_replace('\\', '/', (string)$rule), '/');
|
||||
if ($normalizedRule === '') {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($normalizedRule, '/')) {
|
||||
if (in_array($normalizedRule, $segments, true)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
|
||||
if (!$rule['is_glob']) {
|
||||
if (!$rule['has_slash']) {
|
||||
return in_array($pattern, $segments, true);
|
||||
}
|
||||
|
||||
$ruleWithGuards = '/' . trim($normalizedRule, '/') . '/';
|
||||
if (str_contains($pathWithGuards, $ruleWithGuards)) {
|
||||
return str_contains('/' . $normalizedPath . '/', '/' . $pattern . '/');
|
||||
}
|
||||
|
||||
if (!$rule['has_slash']) {
|
||||
foreach ($segments as $segment) {
|
||||
if (globMatches($pattern, $segment, $rule)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($segments as $index => $_segment) {
|
||||
$relativeSuffix = implode('/', array_slice($segments, $index));
|
||||
if (globMatches($pattern, $relativeSuffix, $rule)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supports shell-style "*" (any characters) and "?" (one character).
|
||||
*
|
||||
* @param array{pattern:string,is_glob:bool,has_slash:bool,regex:?string} $rule
|
||||
*/
|
||||
function globMatches(string $pattern, string $candidate, array $rule): bool
|
||||
{
|
||||
if (function_exists('fnmatch')) {
|
||||
return fnmatch($pattern, $candidate);
|
||||
}
|
||||
|
||||
$regex = $rule['regex'] ?? globPatternToRegex($pattern);
|
||||
|
||||
return preg_match($regex, $candidate) === 1;
|
||||
}
|
||||
|
||||
function globPatternToRegex(string $pattern): string
|
||||
{
|
||||
$regex = '';
|
||||
$length = strlen($pattern);
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = $pattern[$i];
|
||||
if ($char === '*') {
|
||||
$regex .= '.*';
|
||||
continue;
|
||||
}
|
||||
if ($char === '?') {
|
||||
$regex .= '.';
|
||||
continue;
|
||||
}
|
||||
$regex .= preg_quote($char, '#');
|
||||
}
|
||||
|
||||
return '#^' . $regex . '$#';
|
||||
}
|
||||
|
||||
function formatLocalLogPath(string $path, string $basePath, bool $printRelative): string
|
||||
@ -1121,8 +1201,8 @@ function main(array $argv): int
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,no_print_skip:bool,skip:list<string>,skip_delete:list<string>,actions:list<array<string,string>>} $config
|
||||
* @return array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,no_print_skip:bool,skip:list<string>,skip_delete:list<string>,actions:list<array<string,string>>}
|
||||
* @param array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,no_print_skip:bool,skip:list<array<string,mixed>>,skip_delete:list<array<string,mixed>>,actions:list<array<string,string>>} $config
|
||||
* @return array{help:bool,host:string,user:string,password:string,port:int,print_relative:bool,no_print_skip:bool,skip:list<array<string,mixed>>,skip_delete:list<array<string,mixed>>,actions:list<array<string,string>>}
|
||||
*/
|
||||
function askForMissingRequiredOptions(array $config): array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user