diff --git a/docs/rmlint.1.rst b/docs/rmlint.1.rst index 69fa218a..03fe459a 100644 --- a/docs/rmlint.1.rst +++ b/docs/rmlint.1.rst @@ -359,12 +359,20 @@ Traversal Options :``-b --match-basename``: Only consider those files as dupes that have the same basename. See also - ``man 1 basename``. The comparison of the basenames is case-insensitive. + ``man 1 basename``. The comparison of the basenames is case-insensitive for + ASCII characters only; non-ASCII UTF-8 characters must match exactly. :``-B --unmatched-basename``: Only consider those files as dupes that do not share the same basename. - See also ``man 1 basename``. The comparison of the basenames is case-insensitive. + See also ``man 1 basename``. The comparison of the basenames is case-insensitive for + ASCII characters only; non-ASCII UTF-8 characters must match exactly. + +:``--match-relative-path``: + + Only consider those files as dupes that have the same relative path. + Path components are compared case-insensitive for ASCII characters only; + non-ASCII UTF-8 characters must match exactly. :``-e --match-extension`` / ``-E --no-match-extension`` (**default**): diff --git a/lib/cfg.h b/lib/cfg.h index 158ad6c0..02462a86 100644 --- a/lib/cfg.h +++ b/lib/cfg.h @@ -75,6 +75,7 @@ typedef struct RmCfg { gboolean limits_specified; gboolean filter_mtime; gboolean match_basename; + gboolean match_relative_path; gboolean unmatched_basenames; gboolean match_with_extension; gboolean match_without_extension; diff --git a/lib/cmdline.c b/lib/cmdline.c index 39881af0..27b2149e 100644 --- a/lib/cmdline.c +++ b/lib/cmdline.c @@ -1437,6 +1437,7 @@ bool rm_cmd_parse_args(int argc, char **argv, RmSession *session) { {"must-match-untagged" , 'M' , 0 , G_OPTION_ARG_NONE , &cfg->must_match_untagged , _("Must have twin in untagged dir") , NULL} , {"match-basename" , 'b' , 0 , G_OPTION_ARG_NONE , &cfg->match_basename , _("Only find twins with same basename") , NULL} , {"match-extension" , 'e' , 0 , G_OPTION_ARG_NONE , &cfg->match_with_extension , _("Only find twins with same extension") , NULL} , + {"match-relative-path" , 0 , 0 , G_OPTION_ARG_NONE , &cfg->match_relative_path , _("Only find twins with same relative path") , NULL} , {"match-without-extension" , 'i' , 0 , G_OPTION_ARG_NONE , &cfg->match_without_extension , _("Only find twins with same basename minus extension") , NULL} , {"merge-directories" , 'D' , EMPTY , G_OPTION_ARG_CALLBACK , FUNC(merge_directories) , _("Find duplicate directories") , NULL} , {"honour-dir-layout" , 'j' , EMPTY , G_OPTION_ARG_CALLBACK , FUNC(honour_dir_layout) , _("Only find directories with same file layout") , NULL} , diff --git a/lib/file.c b/lib/file.c index 3e2c835f..0f41aa5e 100644 --- a/lib/file.c +++ b/lib/file.c @@ -180,6 +180,30 @@ gint rm_file_basenames_cmp(const RmFile *file_a, const RmFile *file_b) { return g_ascii_strcasecmp(file_a->folder->basename, file_b->folder->basename); } +gint rm_file_relpaths_cmp(const RmFile *file_a, const RmFile *file_b) { + gint diff = file_a->depth - file_b->depth; + RETURN_IF_NONZERO(diff); + + /* If both files have depth 0, we still need to distinguish them by at + * least their basenames; otherwise, they would always compare equal. */ + if(file_a->depth == 0) { + return rm_file_basenames_cmp(file_a, file_b); + } + RmNode *node_a = file_a->folder; + RmNode *node_b = file_b->folder; + for(gint depth = file_a->depth; depth > 0; --depth) { + g_assert(node_a); + g_assert(node_b); + + diff = g_ascii_strcasecmp(node_a->basename, node_b->basename); + RETURN_IF_NONZERO(diff); + + node_a = node_a->parent; + node_b = node_b->parent; + } + return 0; +} + void rm_file_hardlink_add(RmFile *head, RmFile *link) { if(!head->hardlinks) { head->hardlinks = g_queue_new(); diff --git a/lib/file.h b/lib/file.h index 3246af2f..02d32a02 100644 --- a/lib/file.h +++ b/lib/file.h @@ -377,4 +377,10 @@ RmFile *rm_file_copy(RmFile *file); */ gint rm_file_basenames_cmp(const RmFile *file_a, const RmFile *file_b); +/** + * @brief Compare relative paths of two files + * @return 0 if relative paths match, non-zero otherwise. + */ +gint rm_file_relpaths_cmp(const RmFile *file_a, const RmFile *file_b); + #endif /* end of include guard */ diff --git a/lib/preprocess.c b/lib/preprocess.c index e76848f1..a28fdbdb 100644 --- a/lib/preprocess.c +++ b/lib/preprocess.c @@ -84,6 +84,12 @@ gint rm_file_cmp(const RmFile *file_a, const RmFile *file_b) { if(cfg->match_without_extension) { result = rm_file_cmp_without_extension(file_a, file_b); + RETURN_IF_NONZERO(result); + } + + if(cfg->match_relative_path) { + result = rm_file_relpaths_cmp(file_a, file_b); + RETURN_IF_NONZERO(result); } return result; diff --git a/tests/test_options/test_match_relative_path.py b/tests/test_options/test_match_relative_path.py new file mode 100644 index 00000000..7d02abcb --- /dev/null +++ b/tests/test_options/test_match_relative_path.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +from tests.utils import * + + +def test_positive_match_relative_path(usual_setup_usual_teardown): + create_file('xxx', 'base_a/snap/test') + create_file('xxx', 'base_b/snap/test') + + head, *data, footer = run_rmlint( + '--match-relative-path {t}/base_a {t}/base_b'.format(t=TESTDIR_NAME), + use_default_dir=False + ) + assert footer['total_files'] == 2 + assert footer['total_lint_size'] == 3 + assert footer['duplicates'] == 1 + + +def test_negative_match_relative_path(usual_setup_usual_teardown): + create_file('xxx', 'base_a/snap1/test') + create_file('xxx', 'base_b/snap2/test') + + head, *data, footer = run_rmlint( + '--match-relative-path {t}/base_a {t}/base_b'.format(t=TESTDIR_NAME), + use_default_dir=False + ) + assert footer['total_files'] == 2 + assert footer['total_lint_size'] == 0 + assert footer['duplicates'] == 0 + + +def test_negative_match_relative_path_unequal_depth(usual_setup_usual_teardown): + create_file('xxx', 'base_a/snap/test') + create_file('xxx', 'base_b/deeper/snap/test') + + head, *data, footer = run_rmlint( + '--match-relative-path {t}/base_a {t}/base_b'.format(t=TESTDIR_NAME), + use_default_dir=False + ) + assert footer['total_files'] == 2 + assert footer['total_lint_size'] == 0 + assert footer['duplicates'] == 0 + + +def test_positive_match_relative_path_depth_4(usual_setup_usual_teardown): + create_file('xxx', 'base_a/l1/l2/l3/test') + create_file('xxx', 'base_b/l1/l2/l3/test') + + head, *data, footer = run_rmlint( + '--match-relative-path {t}/base_a {t}/base_b'.format(t=TESTDIR_NAME), + use_default_dir=False + ) + assert footer['total_files'] == 2 + assert footer['total_lint_size'] == 3 + assert footer['duplicates'] == 1 + + +def test_negative_match_relative_path_explicit_files(usual_setup_usual_teardown): + path_a = create_file('xxx', 'base_a/file_a') + path_b = create_file('xxx', 'base_b/file_b') + + head, *data, footer = run_rmlint( + '--match-relative-path', path_a, path_b, + use_default_dir=False + ) + assert footer['total_files'] == 2 + assert footer['total_lint_size'] == 0 + assert footer['duplicates'] == 0