From 1b0499e0a318badfccddc44eacd83d61a20c58bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:15:08 +0000 Subject: [PATCH 1/7] Initial plan From 15607d92e3bdf17053cd1cbbc317ba140d5c9824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:20:02 +0000 Subject: [PATCH 2/7] Add match-relative-path option with docs and tests Co-authored-by: dictcp <1752436+dictcp@users.noreply.github.com> --- docs/rmlint.1.rst | 11 +++++-- lib/cfg.h | 1 + lib/cmdline.c | 1 + lib/file.c | 19 ++++++++++++ lib/file.h | 6 ++++ lib/preprocess.c | 5 ++++ .../test_options/test_match_relative_path.py | 29 +++++++++++++++++++ 7 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/test_options/test_match_relative_path.py diff --git a/docs/rmlint.1.rst b/docs/rmlint.1.rst index 69fa218a3..09dea056d 100644 --- a/docs/rmlint.1.rst +++ b/docs/rmlint.1.rst @@ -359,12 +359,19 @@ 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. :``-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. + +:``--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. :``-e --match-extension`` / ``-E --no-match-extension`` (**default**): diff --git a/lib/cfg.h b/lib/cfg.h index 158ad6c05..02462a863 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 39881af01..27b2149e7 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 3e2c835f9..75b0e1ad0 100644 --- a/lib/file.c +++ b/lib/file.c @@ -180,6 +180,25 @@ 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); + + RmNode *node_a = file_a->folder; + RmNode *node_b = file_b->folder; + for(gint16 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 3246af2fb..d1a03f8bc 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 + * @retval true if relative paths match. + */ +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 e76848f17..9a4b3a88b 100644 --- a/lib/preprocess.c +++ b/lib/preprocess.c @@ -84,6 +84,11 @@ 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 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 000000000..5f92ad3ce --- /dev/null +++ b/tests/test_options/test_match_relative_path.py @@ -0,0 +1,29 @@ +#!/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 From 14dc9927347f3fbcc7649d1fd709481e146b0bc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:22:40 +0000 Subject: [PATCH 3/7] Address review feedback and finalize match-relative-path changes Co-authored-by: dictcp <1752436+dictcp@users.noreply.github.com> --- lib/file.c | 2 +- lib/file.h | 2 +- lib/preprocess.c | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/file.c b/lib/file.c index 75b0e1ad0..fd17e8861 100644 --- a/lib/file.c +++ b/lib/file.c @@ -186,7 +186,7 @@ gint rm_file_relpaths_cmp(const RmFile *file_a, const RmFile *file_b) { RmNode *node_a = file_a->folder; RmNode *node_b = file_b->folder; - for(gint16 depth = file_a->depth; depth > 0; --depth) { + for(gint depth = file_a->depth; depth > 0; --depth) { g_assert(node_a); g_assert(node_b); diff --git a/lib/file.h b/lib/file.h index d1a03f8bc..02d32a029 100644 --- a/lib/file.h +++ b/lib/file.h @@ -379,7 +379,7 @@ gint rm_file_basenames_cmp(const RmFile *file_a, const RmFile *file_b); /** * @brief Compare relative paths of two files - * @retval true if relative paths match. + * @return 0 if relative paths match, non-zero otherwise. */ gint rm_file_relpaths_cmp(const RmFile *file_a, const RmFile *file_b); diff --git a/lib/preprocess.c b/lib/preprocess.c index 9a4b3a88b..a28fdbdb6 100644 --- a/lib/preprocess.c +++ b/lib/preprocess.c @@ -89,6 +89,7 @@ gint rm_file_cmp(const RmFile *file_a, const RmFile *file_b) { if(cfg->match_relative_path) { result = rm_file_relpaths_cmp(file_a, file_b); + RETURN_IF_NONZERO(result); } return result; From 3299dc9a60dfe486210e28c2618990ffd8125e8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:26:49 +0000 Subject: [PATCH 4/7] Clarify UTF-8 behavior for ASCII-only case-insensitive matching Co-authored-by: dictcp <1752436+dictcp@users.noreply.github.com> --- docs/rmlint.1.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/rmlint.1.rst b/docs/rmlint.1.rst index 09dea056d..03fe459ab 100644 --- a/docs/rmlint.1.rst +++ b/docs/rmlint.1.rst @@ -360,18 +360,19 @@ Traversal Options Only consider those files as dupes that have the same basename. See also ``man 1 basename``. The comparison of the basenames is case-insensitive for - ASCII characters only. + 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 for - ASCII characters only. + 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. + 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**): From 0d8ddb3db5610382cd15adc657baba1200822975 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:40:59 +0000 Subject: [PATCH 5/7] Add deeper and unequal-depth match-relative-path tests Co-authored-by: dictcp <1752436+dictcp@users.noreply.github.com> --- .../test_options/test_match_relative_path.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_options/test_match_relative_path.py b/tests/test_options/test_match_relative_path.py index 5f92ad3ce..19e46bd12 100644 --- a/tests/test_options/test_match_relative_path.py +++ b/tests/test_options/test_match_relative_path.py @@ -27,3 +27,29 @@ def test_negative_match_relative_path(usual_setup_usual_teardown): 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 From e85305ea7aab3bbcd279bada0f4602653fc571f4 Mon Sep 17 00:00:00 2001 From: Dick Tang Date: Wed, 4 Mar 2026 16:58:29 +0800 Subject: [PATCH 6/7] Update lib/file.c Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/file.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/file.c b/lib/file.c index fd17e8861..0f41aa5e0 100644 --- a/lib/file.c +++ b/lib/file.c @@ -184,6 +184,11 @@ 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) { From d88a0f3d41e7fde9c7e7ec30c199d9e9d1c2d0e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:13:39 +0000 Subject: [PATCH 7/7] Add explicit file-input regression test for match-relative-path Co-authored-by: dictcp <1752436+dictcp@users.noreply.github.com> --- tests/test_options/test_match_relative_path.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_options/test_match_relative_path.py b/tests/test_options/test_match_relative_path.py index 19e46bd12..7d02abcb6 100644 --- a/tests/test_options/test_match_relative_path.py +++ b/tests/test_options/test_match_relative_path.py @@ -53,3 +53,16 @@ def test_positive_match_relative_path_depth_4(usual_setup_usual_teardown): 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