From 186600f7d5577134138d580e3a3bd5d93b5bb2c6 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Tue, 7 Apr 2026 09:14:37 -0700 Subject: [PATCH 1/5] fix: accumulate all username removals instead of only preserving the last one When multiple non-org-member usernames need to be removed from a CODEOWNERS file, the removal loop was replacing from the original content on each iteration. This meant only the last username's removal survived in the resulting PR. Initialize codeowners_file_contents_new with the decoded content and accumulate replacements so all removals are preserved. Fixes #380 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Zack Koppert --- cleanowners.py | 6 +++--- test_cleanowners.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cleanowners.py b/cleanowners.py index 30cdde5..98cecc4 100644 --- a/cleanowners.py +++ b/cleanowners.py @@ -143,7 +143,7 @@ def main(): # pragma: no cover usernames = get_usernames_from_codeowners(codeowners_decoded) usernames_to_remove = [] - codeowners_file_contents_new = None + codeowners_file_contents_new = codeowners_decoded for username in usernames: org = organization if organization else repo.owner.login gh_org = get_org(github_connection, org) @@ -162,8 +162,8 @@ def main(): # pragma: no cover # Remove that username from the codeowners_file_contents file_changed = True bytes_username = f"@{username}".encode("ASCII") - codeowners_file_contents_new = codeowners_decoded.replace( - bytes_username, b"" + codeowners_file_contents_new = ( + codeowners_file_contents_new.replace(bytes_username, b"") ) # Store the repo and users to remove for reporting later diff --git a/test_cleanowners.py b/test_cleanowners.py index eb1cfc4..84023ee 100644 --- a/test_cleanowners.py +++ b/test_cleanowners.py @@ -147,6 +147,29 @@ def test_get_usernames_from_codeowners_with_raw_bytes(self): self.assertEqual(result, expected_usernames) + def test_multiple_username_removals_are_cumulative(self): + """Test that removing multiple usernames preserves all removals. + + Regression test for https://github.com/github-community-projects/cleanowners/issues/380 + The removal loop must accumulate changes rather than replacing from the + original content each time, otherwise only the last removal survives. + """ + codeowners_decoded = b"* @alice @bob @charlie\ndocs/* @alice\n" + usernames_to_remove = ["alice", "bob"] + + # Replicate the accumulative removal pattern from main() + codeowners_file_contents_new = codeowners_decoded + for username in usernames_to_remove: + bytes_username = f"@{username}".encode("ASCII") + codeowners_file_contents_new = codeowners_file_contents_new.replace( + bytes_username, b"" + ) + + remaining = get_usernames_from_codeowners(codeowners_file_contents_new) + self.assertEqual(remaining, ["charlie"]) + self.assertNotIn(b"@alice", codeowners_file_contents_new) + self.assertNotIn(b"@bob", codeowners_file_contents_new) + class TestGetOrganization(unittest.TestCase): """Test the get_org function in cleanowners.py""" From 4859d2d16eb0c5a7f511a4afbc2df6695cb49afe Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Tue, 7 Apr 2026 09:33:45 -0700 Subject: [PATCH 2/5] fix: use regex word-boundary matching for username removal Replace bytes.replace() with re.sub() using a lookahead assertion (?=\s|$) so that removing @bob does not corrupt @bobsmith. The previous substring matching was a pre-existing issue, but the accumulation fix made it worse because corrupted content now persists across iterations instead of being overwritten. Also update the existing regression test to use the new regex pattern and add a dedicated test for the substring edge case. Closes #380 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Zack Koppert --- cleanowners.py | 9 ++++++--- test_cleanowners.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/cleanowners.py b/cleanowners.py index 98cecc4..f5c8859 100644 --- a/cleanowners.py +++ b/cleanowners.py @@ -1,5 +1,6 @@ """A GitHub Action to suggest removal of non-organization members from CODEOWNERS files.""" +import re import uuid import auth @@ -161,9 +162,11 @@ def main(): # pragma: no cover if not dry_run: # Remove that username from the codeowners_file_contents file_changed = True - bytes_username = f"@{username}".encode("ASCII") - codeowners_file_contents_new = ( - codeowners_file_contents_new.replace(bytes_username, b"") + pattern = re.escape(f"@{username}".encode("ASCII")) + codeowners_file_contents_new = re.sub( + pattern + rb"(?=\s|$)", + b"", + codeowners_file_contents_new, ) # Store the repo and users to remove for reporting later diff --git a/test_cleanowners.py b/test_cleanowners.py index 84023ee..bfa5dd9 100644 --- a/test_cleanowners.py +++ b/test_cleanowners.py @@ -1,5 +1,6 @@ """Test the functions in the cleanowners module.""" +import re import unittest import uuid from io import StringIO @@ -160,9 +161,11 @@ def test_multiple_username_removals_are_cumulative(self): # Replicate the accumulative removal pattern from main() codeowners_file_contents_new = codeowners_decoded for username in usernames_to_remove: - bytes_username = f"@{username}".encode("ASCII") - codeowners_file_contents_new = codeowners_file_contents_new.replace( - bytes_username, b"" + pattern = re.escape(f"@{username}".encode("ASCII")) + codeowners_file_contents_new = re.sub( + pattern + rb"(?=\s|$)", + b"", + codeowners_file_contents_new, ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) @@ -170,6 +173,29 @@ def test_multiple_username_removals_are_cumulative(self): self.assertNotIn(b"@alice", codeowners_file_contents_new) self.assertNotIn(b"@bob", codeowners_file_contents_new) + def test_username_removal_does_not_corrupt_similar_names(self): + """Test that removing @bob does not corrupt @bobsmith. + + The removal must use word-boundary matching so that @bob only matches + the exact handle, not as a prefix of @bobsmith. + """ + codeowners_decoded = b"* @bobsmith @bob @charlie\n" + usernames_to_remove = ["bob"] + + codeowners_file_contents_new = codeowners_decoded + for username in usernames_to_remove: + pattern = re.escape(f"@{username}".encode("ASCII")) + codeowners_file_contents_new = re.sub( + pattern + rb"(?=\s|$)", + b"", + codeowners_file_contents_new, + ) + + remaining = get_usernames_from_codeowners(codeowners_file_contents_new) + self.assertEqual(remaining, ["bobsmith", "charlie"]) + self.assertIn(b"@bobsmith", codeowners_file_contents_new) + self.assertNotIn(b"@bob ", codeowners_file_contents_new) + class TestGetOrganization(unittest.TestCase): """Test the get_org function in cleanowners.py""" From 613fbc76a75464a3408e4a3ded254f5a1240441f Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Tue, 7 Apr 2026 09:44:10 -0700 Subject: [PATCH 3/5] fix: clean up extra whitespace after username removal Collapse runs of multiple spaces/tabs to a single space and strip trailing whitespace from each line after usernames are removed. Without this, removing @bob from '@alice @bob @charlie' would leave a double space between @alice and @charlie. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Zack Koppert --- cleanowners.py | 12 ++++++++++++ test_cleanowners.py | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/cleanowners.py b/cleanowners.py index f5c8859..33df320 100644 --- a/cleanowners.py +++ b/cleanowners.py @@ -173,6 +173,18 @@ def main(): # pragma: no cover if usernames_to_remove: repo_and_users_to_remove[repo] = usernames_to_remove + # Clean up extra whitespace left by username removals + if file_changed: + codeowners_file_contents_new = re.sub( + rb"[ \t]{2,}", b" ", codeowners_file_contents_new + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]+$", + b"", + codeowners_file_contents_new, + flags=re.MULTILINE, + ) + # Update the CODEOWNERS file if usernames were removed if file_changed: eligble_for_pr_count += 1 diff --git a/test_cleanowners.py b/test_cleanowners.py index bfa5dd9..fbc8883 100644 --- a/test_cleanowners.py +++ b/test_cleanowners.py @@ -158,7 +158,7 @@ def test_multiple_username_removals_are_cumulative(self): codeowners_decoded = b"* @alice @bob @charlie\ndocs/* @alice\n" usernames_to_remove = ["alice", "bob"] - # Replicate the accumulative removal pattern from main() + # Replicate the removal pattern from main() codeowners_file_contents_new = codeowners_decoded for username in usernames_to_remove: pattern = re.escape(f"@{username}".encode("ASCII")) @@ -167,6 +167,12 @@ def test_multiple_username_removals_are_cumulative(self): b"", codeowners_file_contents_new, ) + codeowners_file_contents_new = re.sub( + rb"[ \t]{2,}", b" ", codeowners_file_contents_new + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]+$", b"", codeowners_file_contents_new, flags=re.MULTILINE + ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) self.assertEqual(remaining, ["charlie"]) @@ -190,12 +196,45 @@ def test_username_removal_does_not_corrupt_similar_names(self): b"", codeowners_file_contents_new, ) + codeowners_file_contents_new = re.sub( + rb"[ \t]{2,}", b" ", codeowners_file_contents_new + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]+$", b"", codeowners_file_contents_new, flags=re.MULTILINE + ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) self.assertEqual(remaining, ["bobsmith", "charlie"]) self.assertIn(b"@bobsmith", codeowners_file_contents_new) self.assertNotIn(b"@bob ", codeowners_file_contents_new) + def test_username_removal_cleans_up_whitespace(self): + """Test that removing usernames does not leave extra whitespace. + + After removing a username from between two others, the resulting + double space should be collapsed to a single space, and trailing + whitespace should be stripped. + """ + codeowners_decoded = b"* @alice @bob @charlie\n" + usernames_to_remove = ["bob"] + + codeowners_file_contents_new = codeowners_decoded + for username in usernames_to_remove: + pattern = re.escape(f"@{username}".encode("ASCII")) + codeowners_file_contents_new = re.sub( + pattern + rb"(?=\s|$)", + b"", + codeowners_file_contents_new, + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]{2,}", b" ", codeowners_file_contents_new + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]+$", b"", codeowners_file_contents_new, flags=re.MULTILINE + ) + + self.assertEqual(codeowners_file_contents_new, b"* @alice @charlie\n") + class TestGetOrganization(unittest.TestCase): """Test the get_org function in cleanowners.py""" From e7d12a632997c11afa4c42d7bb04f7a1fbd9a628 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Tue, 7 Apr 2026 09:56:51 -0700 Subject: [PATCH 4/5] fix: handle CRLF line endings in whitespace cleanup Change trailing whitespace pattern from [ \t]+$ to [ \t]+\r?$ so that spaces before \r\n are also stripped. Without this, Windows- style CRLF files would retain trailing spaces after username removal. Add dedicated CRLF test case to cover this edge case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Zack Koppert --- cleanowners.py | 2 +- test_cleanowners.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cleanowners.py b/cleanowners.py index 33df320..c174cd8 100644 --- a/cleanowners.py +++ b/cleanowners.py @@ -179,7 +179,7 @@ def main(): # pragma: no cover rb"[ \t]{2,}", b" ", codeowners_file_contents_new ) codeowners_file_contents_new = re.sub( - rb"[ \t]+$", + rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE, diff --git a/test_cleanowners.py b/test_cleanowners.py index fbc8883..68babee 100644 --- a/test_cleanowners.py +++ b/test_cleanowners.py @@ -171,7 +171,7 @@ def test_multiple_username_removals_are_cumulative(self): rb"[ \t]{2,}", b" ", codeowners_file_contents_new ) codeowners_file_contents_new = re.sub( - rb"[ \t]+$", b"", codeowners_file_contents_new, flags=re.MULTILINE + rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) @@ -200,7 +200,7 @@ def test_username_removal_does_not_corrupt_similar_names(self): rb"[ \t]{2,}", b" ", codeowners_file_contents_new ) codeowners_file_contents_new = re.sub( - rb"[ \t]+$", b"", codeowners_file_contents_new, flags=re.MULTILINE + rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) @@ -230,11 +230,37 @@ def test_username_removal_cleans_up_whitespace(self): rb"[ \t]{2,}", b" ", codeowners_file_contents_new ) codeowners_file_contents_new = re.sub( - rb"[ \t]+$", b"", codeowners_file_contents_new, flags=re.MULTILINE + rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE ) self.assertEqual(codeowners_file_contents_new, b"* @alice @charlie\n") + def test_username_removal_handles_crlf_line_endings(self): + """Test that whitespace cleanup works with CRLF line endings. + + Windows-style line endings use \\r\\n. The trailing whitespace + cleanup must strip spaces before \\r\\n, not just before \\n. + """ + codeowners_decoded = b"* @alice @bob @charlie\r\n" + usernames_to_remove = ["bob"] + + codeowners_file_contents_new = codeowners_decoded + for username in usernames_to_remove: + pattern = re.escape(f"@{username}".encode("ASCII")) + codeowners_file_contents_new = re.sub( + pattern + rb"(?=\s|$)", + b"", + codeowners_file_contents_new, + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]{2,}", b" ", codeowners_file_contents_new + ) + codeowners_file_contents_new = re.sub( + rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE + ) + + self.assertEqual(codeowners_file_contents_new, b"* @alice @charlie\r\n") + class TestGetOrganization(unittest.TestCase): """Test the get_org function in cleanowners.py""" From 4368cfc742afa255faa8b9fac49caf65af4a91bf Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Tue, 7 Apr 2026 14:40:50 -0700 Subject: [PATCH 5/5] refactor: extract helpers and scope whitespace cleanup to changed lines Address review feedback from jmeridth: 1. CRLF preservation: Change trailing whitespace regex from [\ \t]+\r?$ (which consumed \r) to [\t]+(?=\r?$) (lookahead preserves \r). 2. Scoped whitespace cleanup: Track which line indices had username removals via a changed_lines set, and only normalize whitespace on those lines. This prevents collapsing intentional alignment on unrelated CODEOWNERS lines. Also extract remove_username_from_content() and cleanup_whitespace() helper functions to reduce nesting depth in main() and make the logic directly testable. Add test_whitespace_cleanup_scoped_to_changed_lines to verify unrelated lines with intentional alignment are preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Zack Koppert --- cleanowners.py | 58 ++++++++++++++++++++++------- test_cleanowners.py | 90 +++++++++++++++++++++++---------------------- 2 files changed, 91 insertions(+), 57 deletions(-) diff --git a/cleanowners.py b/cleanowners.py index c174cd8..0afe7d8 100644 --- a/cleanowners.py +++ b/cleanowners.py @@ -18,6 +18,44 @@ def get_org(github_connection, organization): return None +def remove_username_from_content(content, username, changed_lines): + """Remove a @username from CODEOWNERS content using line-scoped regex. + + Args: + content: The current CODEOWNERS file content as bytes. + username: The GitHub username to remove (without @). + changed_lines: A set[int] tracking which line indices were modified. + + Returns: + The updated content with the username removed. + """ + pattern = re.escape(f"@{username}".encode("ASCII")) + lines = content.split(b"\n") + for i, line in enumerate(lines): + new_line = re.sub(pattern + rb"(?=\s|$)", b"", line) + if new_line != line: + lines[i] = new_line + changed_lines.add(i) + return b"\n".join(lines) + + +def cleanup_whitespace(content, changed_lines): + """Normalize whitespace only on lines where usernames were removed. + + Args: + content: The CODEOWNERS file content as bytes. + changed_lines: A set[int] of line indices to clean up. + + Returns: + The content with extra whitespace removed on affected lines. + """ + lines = content.split(b"\n") + for i in changed_lines: + lines[i] = re.sub(rb"[ \t]{2,}", b" ", lines[i]) + lines[i] = re.sub(rb"[ \t]+(?=\r?$)", b"", lines[i]) + return b"\n".join(lines) + + def main(): # pragma: no cover """Run the main program""" @@ -145,6 +183,7 @@ def main(): # pragma: no cover usernames_to_remove = [] codeowners_file_contents_new = codeowners_decoded + changed_lines: set[int] = set() for username in usernames: org = organization if organization else repo.owner.login gh_org = get_org(github_connection, org) @@ -162,27 +201,18 @@ def main(): # pragma: no cover if not dry_run: # Remove that username from the codeowners_file_contents file_changed = True - pattern = re.escape(f"@{username}".encode("ASCII")) - codeowners_file_contents_new = re.sub( - pattern + rb"(?=\s|$)", - b"", - codeowners_file_contents_new, + codeowners_file_contents_new = remove_username_from_content( + codeowners_file_contents_new, username, changed_lines ) # Store the repo and users to remove for reporting later if usernames_to_remove: repo_and_users_to_remove[repo] = usernames_to_remove - # Clean up extra whitespace left by username removals + # Clean up extra whitespace only on lines where usernames were removed if file_changed: - codeowners_file_contents_new = re.sub( - rb"[ \t]{2,}", b" ", codeowners_file_contents_new - ) - codeowners_file_contents_new = re.sub( - rb"[ \t]+\r?$", - b"", - codeowners_file_contents_new, - flags=re.MULTILINE, + codeowners_file_contents_new = cleanup_whitespace( + codeowners_file_contents_new, changed_lines ) # Update the CODEOWNERS file if usernames were removed diff --git a/test_cleanowners.py b/test_cleanowners.py index 68babee..87dd40b 100644 --- a/test_cleanowners.py +++ b/test_cleanowners.py @@ -1,6 +1,5 @@ """Test the functions in the cleanowners module.""" -import re import unittest import uuid from io import StringIO @@ -9,12 +8,14 @@ import github3 from cleanowners import ( build_default_codeowners, + cleanup_whitespace, commit_changes, get_codeowners_file, get_org, get_repos_iterator, get_usernames_from_codeowners, print_stats, + remove_username_from_content, ) @@ -158,20 +159,14 @@ def test_multiple_username_removals_are_cumulative(self): codeowners_decoded = b"* @alice @bob @charlie\ndocs/* @alice\n" usernames_to_remove = ["alice", "bob"] - # Replicate the removal pattern from main() codeowners_file_contents_new = codeowners_decoded + changed_lines: set[int] = set() for username in usernames_to_remove: - pattern = re.escape(f"@{username}".encode("ASCII")) - codeowners_file_contents_new = re.sub( - pattern + rb"(?=\s|$)", - b"", - codeowners_file_contents_new, + codeowners_file_contents_new = remove_username_from_content( + codeowners_file_contents_new, username, changed_lines ) - codeowners_file_contents_new = re.sub( - rb"[ \t]{2,}", b" ", codeowners_file_contents_new - ) - codeowners_file_contents_new = re.sub( - rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE + codeowners_file_contents_new = cleanup_whitespace( + codeowners_file_contents_new, changed_lines ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) @@ -189,18 +184,13 @@ def test_username_removal_does_not_corrupt_similar_names(self): usernames_to_remove = ["bob"] codeowners_file_contents_new = codeowners_decoded + changed_lines: set[int] = set() for username in usernames_to_remove: - pattern = re.escape(f"@{username}".encode("ASCII")) - codeowners_file_contents_new = re.sub( - pattern + rb"(?=\s|$)", - b"", - codeowners_file_contents_new, + codeowners_file_contents_new = remove_username_from_content( + codeowners_file_contents_new, username, changed_lines ) - codeowners_file_contents_new = re.sub( - rb"[ \t]{2,}", b" ", codeowners_file_contents_new - ) - codeowners_file_contents_new = re.sub( - rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE + codeowners_file_contents_new = cleanup_whitespace( + codeowners_file_contents_new, changed_lines ) remaining = get_usernames_from_codeowners(codeowners_file_contents_new) @@ -219,18 +209,13 @@ def test_username_removal_cleans_up_whitespace(self): usernames_to_remove = ["bob"] codeowners_file_contents_new = codeowners_decoded + changed_lines: set[int] = set() for username in usernames_to_remove: - pattern = re.escape(f"@{username}".encode("ASCII")) - codeowners_file_contents_new = re.sub( - pattern + rb"(?=\s|$)", - b"", - codeowners_file_contents_new, + codeowners_file_contents_new = remove_username_from_content( + codeowners_file_contents_new, username, changed_lines ) - codeowners_file_contents_new = re.sub( - rb"[ \t]{2,}", b" ", codeowners_file_contents_new - ) - codeowners_file_contents_new = re.sub( - rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE + codeowners_file_contents_new = cleanup_whitespace( + codeowners_file_contents_new, changed_lines ) self.assertEqual(codeowners_file_contents_new, b"* @alice @charlie\n") @@ -239,28 +224,47 @@ def test_username_removal_handles_crlf_line_endings(self): """Test that whitespace cleanup works with CRLF line endings. Windows-style line endings use \\r\\n. The trailing whitespace - cleanup must strip spaces before \\r\\n, not just before \\n. + cleanup must strip spaces before \\r without consuming the \\r itself. """ codeowners_decoded = b"* @alice @bob @charlie\r\n" usernames_to_remove = ["bob"] codeowners_file_contents_new = codeowners_decoded + changed_lines: set[int] = set() for username in usernames_to_remove: - pattern = re.escape(f"@{username}".encode("ASCII")) - codeowners_file_contents_new = re.sub( - pattern + rb"(?=\s|$)", - b"", - codeowners_file_contents_new, + codeowners_file_contents_new = remove_username_from_content( + codeowners_file_contents_new, username, changed_lines ) - codeowners_file_contents_new = re.sub( - rb"[ \t]{2,}", b" ", codeowners_file_contents_new - ) - codeowners_file_contents_new = re.sub( - rb"[ \t]+\r?$", b"", codeowners_file_contents_new, flags=re.MULTILINE + codeowners_file_contents_new = cleanup_whitespace( + codeowners_file_contents_new, changed_lines ) self.assertEqual(codeowners_file_contents_new, b"* @alice @charlie\r\n") + def test_whitespace_cleanup_scoped_to_changed_lines(self): + """Test that whitespace cleanup only affects lines where usernames were removed. + + Lines with intentional alignment spacing should not be modified + if no username was removed from them. + """ + codeowners_decoded = b"src/** @alice @bob @charlie\ndocs/** @dave\n" + usernames_to_remove = ["bob"] + + codeowners_file_contents_new = codeowners_decoded + changed_lines: set[int] = set() + for username in usernames_to_remove: + codeowners_file_contents_new = remove_username_from_content( + codeowners_file_contents_new, username, changed_lines + ) + codeowners_file_contents_new = cleanup_whitespace( + codeowners_file_contents_new, changed_lines + ) + + # src line should be normalized (removal happened there) + self.assertIn(b"src/** @alice @charlie", codeowners_file_contents_new) + # docs line should be untouched (no removal happened there) + self.assertIn(b"docs/** @dave", codeowners_file_contents_new) + class TestGetOrganization(unittest.TestCase): """Test the get_org function in cleanowners.py"""