From 49f78a27161478e624a975a1f55b72a1963b11d6 Mon Sep 17 00:00:00 2001 From: BHFock <5771605+BHFock@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:39:27 +0200 Subject: [PATCH 1/5] Refuse state-mutating operations during unresolved merge stage, unstage, commit, stash, unstash, and checkout now detect unmerged files (UU, AA, DD, AU, UA, DU, UD) and refuse cleanly with a message pointing the user at git merge --abort or manual resolution. Previously these commands could either pass the file to git (which would fail with a cryptic error) or, worse, silently succeed on a partial operation leaving the repo in an unclear state. add, remove, delete, status, and diff are unchanged: they either only touch cl.json or are read-only, and users may legitimately want to reorganise changelists mid-merge. --- git-cl | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/git-cl b/git-cl index 2f375a7..ffe7653 100755 --- a/git-cl +++ b/git-cl @@ -645,6 +645,66 @@ def clutil_is_file_untracked_cached( return status_map.get(file_path_rel_to_git_root, " ") == "??" +def clutil_detect_merge_conflicts(status_map: dict[str, str]) -> list[str]: + """ + Return a list of files currently in a merge-conflict (unmerged) state. + + Unmerged status codes per git-status(1): + UU - both sides modified + AA - both sides added + DD - both sides deleted + AU - added by us, modified by them + UA - added by them, modified by us + DU - deleted by us, modified by them + UD - modified by us, deleted by them + + A file in any of these states cannot be safely staged, committed, + stashed, or checked out via git-cl while the merge is unresolved. + + Args: + status_map: Precomputed status map from clutil_get_file_status_map + + Returns: + Sorted list of file paths (relative to git root) in a conflict state. + """ + conflict_codes = {'UU', 'AA', 'DD', 'AU', 'UA', 'DU', 'UD'} + return sorted(path for path, code in status_map.items() + if code in conflict_codes) + + +def clutil_refuse_on_merge_conflict( + status_map: dict[str, str], operation: str) -> bool: + """ + Check for merge conflicts and print a refusal message if any are found. + + Used at the top of state-mutating commands (stage, unstage, commit, + stash, checkout) to bail out cleanly rather than letting git produce + cryptic errors or leave the repo in an inconsistent state. + + Args: + status_map: Precomputed status map from clutil_get_file_status_map + operation: Short verb for the error message (e.g. 'stage', 'commit') + + Returns: + True if a conflict was detected and the caller should return. + False if the caller may proceed. + """ + conflicts = clutil_detect_merge_conflicts(status_map) + if not conflicts: + return False + + print(f"Error: Cannot {operation} while merge conflicts are unresolved.") + print("The following files have conflicts:") + for path in conflicts: + print(f" [{status_map[path]}] {path}") + print("\nResolve conflicts first:") + print(" 1. Edit the files to resolve conflicts") + print(" 2. Stage resolved files: git add ") + print(" 3. Complete the merge: git commit") + print("\nOr abort the merge: git merge --abort") + return True + + def clutil_get_stash_file() -> Path: """ Returns the path to the stash metadata file inside the Git directory. @@ -2346,6 +2406,9 @@ def cl_stage(args: argparse.Namespace) -> None: # Fetch git status once and reuse for all files in the changelist status_map = clutil_get_file_status_map(show_all=True) + if clutil_refuse_on_merge_conflict(status_map, "stage"): + return + for stored_path in changelists[name]: # Convert stored path (relative to git root) to absolute path abs_path = (git_root / stored_path).resolve() @@ -2400,6 +2463,9 @@ def cl_unstage(args: argparse.Namespace) -> None: # Get current status to identify staged files status_map = clutil_get_file_status_map(show_all=True) + if clutil_refuse_on_merge_conflict(status_map, "unstage"): + return + for stored_path in changelists[name]: # Convert stored path (relative to git root) to absolute path abs_path = (git_root / stored_path).resolve() @@ -2547,6 +2613,11 @@ def cl_checkout(args: argparse.Namespace) -> None: changelists = clutil_load() git_root = clutil_get_git_root() + # Check for merge conflicts before doing anything destructive + status_map = clutil_get_file_status_map(show_all=True) + if clutil_refuse_on_merge_conflict(status_map, "checkout"): + return + # Collect all files from specified changelists all_files = [] missing_changelists = [] @@ -2740,6 +2811,9 @@ def cl_commit(args: argparse.Namespace) -> None: # Fetch git status once and reuse for all files in the changelist status_map = clutil_get_file_status_map(show_all=True) + if clutil_refuse_on_merge_conflict(status_map, "commit"): + return + for stored_path in changelists[name]: # Convert stored path (relative to git root) to absolute path abs_path = (git_root / stored_path).resolve() @@ -2821,6 +2895,10 @@ def cl_stash(args: argparse.Namespace, quiet: bool = False) -> None: # Get file statuses and categorize status_map = clutil_get_file_status_map(show_all=True) + + if clutil_refuse_on_merge_conflict(status_map, "stash"): + return + categorization = clutil_categorize_files_for_stash( existing_files, missing_files, status_map, git_root ) @@ -2893,6 +2971,10 @@ def cl_unstash(args: argparse.Namespace, quiet: bool = False) -> None: stashes = clutil_load_stashes() changelists = clutil_load() + status_map = clutil_get_file_status_map(show_all=True) + if clutil_refuse_on_merge_conflict(status_map, "unstash"): + return + # Handle --all flag if getattr(args, 'all', False): clutil_unstash_all_changelists(stashes, getattr(args, 'force', False)) From 162e9366b6aafb69cea5795897d07c3003a6989b Mon Sep 17 00:00:00 2001 From: BHFock <5771605+BHFock@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:37:27 +0200 Subject: [PATCH 2/5] Add test_merge_conflict.py Covers the new merge-conflict guards in stage, unstage, commit, stash, unstash, and checkout. Also verifies that add and remove still work mid-merge, and that all commands recover after the conflict is resolved. Conflict setup is done inline (two branches modifying the same file, then git merge) so the exported shell walkthrough shows the full reproduction steps. --- tests/test_merge_conflict.py | 264 +++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100755 tests/test_merge_conflict.py diff --git a/tests/test_merge_conflict.py b/tests/test_merge_conflict.py new file mode 100755 index 0000000..1f2779c --- /dev/null +++ b/tests/test_merge_conflict.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +test_merge_conflict.py — Test refusal of state-mutating commands during +unresolved merge conflicts. + +Covers: + git cl stage — refuses during merge conflict + git cl unstage — refuses during merge conflict + git cl commit — refuses during merge conflict + git cl stash — refuses during merge conflict + git cl unstash — refuses during merge conflict + git cl checkout — refuses during merge conflict + git cl add / rm — still work (metadata only, no git state change) + +What you'll learn: + - git-cl detects unresolved merge conflicts (UU, AA, DD, etc.) and + refuses to stage, unstage, commit, stash, unstash, or checkout + - The refusal is clean: the user sees what's conflicted and how to + resolve it, rather than a cryptic git error + - Changelist organisation commands (add, remove) still work, so + users can rearrange changelists mid-merge if they want to + - After resolving the conflict, all commands work normally again + +Run: + ./test_merge_conflict.py + +Export as shell walkthrough: + ./test_merge_conflict.py --export > walkthrough_merge_conflict.sh +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from test_helpers import TestRepo + + +def create_merge_conflict(repo: TestRepo, file_path: str = "shared.txt"): + """ + Create an unresolved merge conflict on the given file. + + Sets up two branches that both modify the same file, then attempts + to merge them. The merge fails, leaving the file in a UU state. + + After this function returns: + - The repo is on the original branch + - `file_path` has a merge conflict (status UU) + - MERGE_HEAD exists — the repo is mid-merge + """ + original_branch = repo.get_current_branch() + + # Start from a clean baseline with a committed version of the file + repo.write_file(file_path, "original line") + repo.run(f"git add {file_path}") + repo.run(["git", "commit", "--quiet", "-m", "Add shared file"]) + + # Create a side branch with its own version + repo.run("git checkout --quiet -b conflict-branch") + repo.write_file(file_path, "branch version") + repo.run(f"git add {file_path}") + repo.run(["git", "commit", "--quiet", "-m", "Branch change"]) + + # Back to the original branch, make a conflicting change + repo.run(f"git checkout --quiet {original_branch}") + repo.write_file(file_path, "original change") + repo.run(f"git add {file_path}") + repo.run(["git", "commit", "--quiet", "-m", "Original change"]) + + # Attempt the merge — this should fail with a conflict + repo.run("git merge conflict-branch") + # Not asserting exit code here — merge is expected to fail with + # conflict, and we're about to check the conflict exists anyway + + +def run_tests(repo: TestRepo): + + # ================================================================= + # Setup: create an unresolved merge conflict + # ================================================================= + + repo.section("Setup: create an unresolved merge conflict") + + create_merge_conflict(repo, "shared.txt") + + # Verify the conflict is present + output = repo.run("git status --porcelain") + repo.assert_in("UU shared.txt", output, + "shared.txt is in conflict state (UU)") + + # Create an additional tracked file we can safely add to a changelist + repo.write_file("safe.txt", "safe content") + repo.run("git add safe.txt") + # Don't commit — we want modifications for stage/commit tests, but + # the merge is mid-progress so we leave safe.txt staged-and-added. + # Actually: reset it so we can use it as an unstaged modification later. + repo.run("git reset --quiet HEAD -- safe.txt") + + # ================================================================= + # Test: stage refuses during merge conflict + # ================================================================= + + repo.section("stage refuses during merge conflict") + + repo.run("git cl add my-list safe.txt") + + output = repo.run("git cl stage my-list") + repo.assert_in("Cannot stage while merge conflicts are unresolved", + output, "stage refuses with clear message") + repo.assert_in("shared.txt", output, + "refusal message names the conflicted file") + repo.assert_in("[UU]", output, + "refusal shows the conflict status code") + + # The changelist should be untouched + cl = repo.load_cl_json() + repo.assert_true("my-list" in cl, + "changelist preserved after refusal") + + # ================================================================= + # Test: unstage refuses during merge conflict + # ================================================================= + + repo.section("unstage refuses during merge conflict") + + output = repo.run("git cl unstage my-list") + repo.assert_in("Cannot unstage while merge conflicts are unresolved", + output, "unstage refuses with clear message") + + # ================================================================= + # Test: commit refuses during merge conflict + # ================================================================= + + repo.section("commit refuses during merge conflict") + + output = repo.run("git cl commit my-list -m 'should not happen'") + repo.assert_in("Cannot commit while merge conflicts are unresolved", + output, "commit refuses with clear message") + + # Changelist must survive a refused commit + cl = repo.load_cl_json() + repo.assert_true("my-list" in cl, + "changelist preserved after refused commit") + + # ================================================================= + # Test: stash refuses during merge conflict + # ================================================================= + + repo.section("stash refuses during merge conflict") + + output = repo.run("git cl stash my-list") + repo.assert_in("Cannot stash while merge conflicts are unresolved", + output, "stash refuses with clear message") + + cl = repo.load_cl_json() + repo.assert_true("my-list" in cl, + "changelist not moved to stash after refusal") + + stash = repo.load_stash_json() + repo.assert_true("my-list" not in stash, + "no stash entry created") + + # ================================================================= + # Test: checkout refuses during merge conflict + # ================================================================= + + repo.section("checkout refuses during merge conflict") + + output = repo.run("git cl checkout my-list --force") + repo.assert_in("Cannot checkout while merge conflicts are unresolved", + output, "checkout refuses with clear message") + + # The changelist must survive + cl = repo.load_cl_json() + repo.assert_true("my-list" in cl, + "changelist preserved after refused checkout") + + # ================================================================= + # Test: unstash refuses during merge conflict + # ================================================================= + # We can't create a real stash during a merge (stash itself refuses), + # so unstash's protection is checked against the empty stash: the + # refusal fires before the "no such stash" error would. + + repo.section("unstash refuses during merge conflict") + + output = repo.run("git cl unstash some-name") + repo.assert_in("Cannot unstash while merge conflicts are unresolved", + output, "unstash refuses before checking stash existence") + + # ================================================================= + # Test: add still works during merge conflict + # ================================================================= + # Organising changelists doesn't touch git state, so it should be + # allowed mid-merge. A user might want to pull the conflicted file + # out of a changelist so they can resolve it manually. + + repo.section("add still works during merge conflict") + + output = repo.run("git cl add organised-list safe.txt") + repo.assert_exit_code(0, "add succeeds during merge conflict") + + cl = repo.load_cl_json() + repo.assert_in("safe.txt", cl["organised-list"], + "file added to changelist during merge") + + # ================================================================= + # Test: remove still works during merge conflict + # ================================================================= + + repo.section("remove still works during merge conflict") + + output = repo.run("git cl rm safe.txt") + repo.assert_exit_code(0, "remove succeeds during merge conflict") + + # ================================================================= + # Test: commands work again after resolving the conflict + # ================================================================= + + repo.section("commands work again after resolving the conflict") + + # Resolve the conflict by writing a final version and committing + repo.write_file("shared.txt", "resolved version") + repo.run("git add shared.txt") + repo.run(["git", "commit", "--quiet", "-m", "Resolve merge conflict"]) + + # Verify we're no longer in a merge state + output = repo.run("git status --porcelain") + repo.assert_not_in("UU", output, "no conflicts remain after resolution") + + # Use a fresh file to avoid inherited state from the mid-merge setup + repo.write_file("post-merge.txt", "fresh file") + repo.run("git add post-merge.txt") + repo.run(["git", "commit", "--quiet", "-m", "Add post-merge file"]) + repo.write_file("post-merge.txt", "modified after merge") + + repo.run("git cl add post-merge-list post-merge.txt") + + # Staging should now succeed + output = repo.run("git cl stage post-merge-list") + repo.assert_exit_code(0, "stage works again after merge resolution") + + staged = repo.get_staged_files() + repo.assert_in("post-merge.txt", staged, "post-merge.txt is now staged") + + +# ================================================================= +# Entry point +# ================================================================= + +if __name__ == "__main__": + + if "--help" in sys.argv: + print("Usage: ./test_merge_conflict.py [--export]\n") + print("Options:") + print(" --export Print a shell walkthrough instead of test output.") + print(" --help Show this message.") + sys.exit(0) + + export_mode = "--export" in sys.argv + + with TestRepo(quiet=export_mode) as repo: + run_tests(repo) + if export_mode: + print(repo.export_shell("git-cl walkthrough: merge conflict handling")) From f81b5f127535901b27b9bae8e78b9055bac9a62a Mon Sep 17 00:00:00 2001 From: BHFock <5771605+BHFock@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:28:03 +0200 Subject: [PATCH 3/5] Show merge conflicts by default in git cl status Before: `git cl status` during a merge hid conflicted files behind the "uncommon Git status codes" message, requiring --all to see them. After: conflict codes (UU, AA, DD, AU, UA, DU, UD) are part of the default status view, coloured red to match git's own convention, and an advisory line reminds the user to resolve before proceeding. Complements the refusal guards from the preceding commit: the user can now see what needs resolving as easily as they are told they can't proceed without resolving it. --- git-cl | 17 +++++++++++++-- tests/test_merge_conflict.py | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/git-cl b/git-cl index ffe7653..61864f4 100755 --- a/git-cl +++ b/git-cl @@ -446,7 +446,8 @@ def clutil_get_file_status_map(show_all: bool = False) -> dict[str, str]: # Allowlist of known meaningful status codes INTERESTING_CODES = { - '??', ' M', 'M ', 'MM', 'A ', 'AM', ' D', 'D ', 'R ', 'RM' + '??', ' M', 'M ', 'MM', 'A ', 'AM', ' D', 'D ', 'R ', 'RM', + 'UU', 'AA', 'DD', 'AU', 'UA', 'DU', 'UD', # merge conflicts } status_map = {} @@ -510,7 +511,12 @@ def clutil_format_file_status( staged, unstaged = status[0], status[1] - if status == "??": + + CONFLICT_CODES = {'UU', 'AA', 'DD', 'AU', 'UA', 'DU', 'UD'} + + if status in CONFLICT_CODES: + color = Fore.RED + elif status == "??": color = Fore.BLUE elif staged == 'A': color = Fore.GREEN @@ -2544,6 +2550,13 @@ def cl_status(args: argparse.Namespace) -> None: for file in sorted(no_cl_files): print(clutil_format_file_status(file, status_map, git_root, use_color)) + # Advise user if conflicts are present + conflicts = clutil_detect_merge_conflicts(status_map) + if conflicts: + print() + print(f"Note: {len(conflicts)} file(s) with merge conflicts. " + "Resolve before staging, committing, or stashing.") + def cl_diff(args: argparse.Namespace) -> None: """ diff --git a/tests/test_merge_conflict.py b/tests/test_merge_conflict.py index 1f2779c..d43d987 100755 --- a/tests/test_merge_conflict.py +++ b/tests/test_merge_conflict.py @@ -212,6 +212,46 @@ def run_tests(repo: TestRepo): output = repo.run("git cl rm safe.txt") repo.assert_exit_code(0, "remove succeeds during merge conflict") + # ================================================================= + # Test: status shows conflicts in default view + # ================================================================= + # Previously, conflicts were hidden behind --all. Now they're + # shown by default because they're states the user needs to act on. + + repo.section("status shows conflicts in default view") + + output = repo.run("git cl status") + repo.assert_exit_code(0, "git cl status succeeds during merge") + repo.assert_in("shared.txt", output, + "conflicted file visible in default status view") + repo.assert_in("[UU]", output, + "conflict code shown in status output") + repo.assert_not_in("uncommon Git status codes", output, + "conflicts no longer reported as 'uncommon'") + + # ================================================================= + # Test: status shows advisory when conflicts present + # ================================================================= + + repo.section("status shows advisory when conflicts present") + + output = repo.run("git cl status") + repo.assert_in("merge conflicts", output, + "status mentions merge conflicts") + repo.assert_in("Resolve before", output, + "advisory tells user to resolve") + + # ================================================================= + # Test: status alias 'st' shows same conflict handling + # ================================================================= + + repo.section("'st' alias shows conflicts identically") + + output_full = repo.run("git cl status") + output_alias = repo.run("git cl st") + repo.assert_equal(output_full, output_alias, + "status and st produce identical output during merge") + # ================================================================= # Test: commands work again after resolving the conflict # ================================================================= From 64f67167f04b5ecd82fa6abc5526dcb2fc38ea73 Mon Sep 17 00:00:00 2001 From: BHFock <5771605+BHFock@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:33:19 +0200 Subject: [PATCH 4/5] Set version 1.1.6 --- CITATION.cff | 4 ++-- git-cl | 2 +- setup.cfg | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index b83d3d8..2bde83b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,8 +9,8 @@ url: "https://github.com/BHFock/git-cl" repository-code: "https://github.com/BHFock/git-cl" license: BSD-3-Clause doi: "10.5281/zenodo.18722077" -version: "1.1.5" -date-released: "2026-04-16" +version: "1.1.6" +date-released: "2026-04-17" abstract: >- git-cl is a command-line tool that brings changelist support to Git. It introduces a pre-staging layer that allows developers to partition diff --git a/git-cl b/git-cl index 61864f4..b027900 100755 --- a/git-cl +++ b/git-cl @@ -56,7 +56,7 @@ Single file, zero dependencies beyond Python 3.9+ and Git. Cross-platform: Unix (fcntl) and Windows (msvcrt) file locking. """ -__version__ = "1.1.5" +__version__ = "1.1.6" import argparse import datetime diff --git a/setup.cfg b/setup.cfg index c3e92ac..315f6da 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = git-changelists -version = 1.1.5 +version = 1.1.6 author = Bjoern Hendrik Fock description = Git subcommand for named changelist support. Group working directory files by intent, then stage, commit, or branch by changelist. long_description = file: README.md From b1bf7ffc7dafd0369ffff504d638a8088f613366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fock?= <5771605+BHFock@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:01:35 +0200 Subject: [PATCH 5/5] Clarify git cl status output and usage --- docs/tutorial.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/docs/tutorial.md b/docs/tutorial.md index 7b3a368..055a998 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -152,9 +152,8 @@ git cl st docs --include-no-cl #### Showing all Git status codes -By default, `git cl status` shows only the most [common status codes](#common-status-codes) (like [M ], [??], [ D], etc.) for clarity. - -To include all Git status codes — including merge conflicts and type changes — use the `--all` flag: +By default, `git cl status` shows common status codes (like [M ], [??], [ D]) together with merge-conflict codes ([UU], [AA], etc.), since conflicts require your immediate attention. Less common codes such as type changes ([T ]) are hidden to keep the output readable. +To include every Git status code, use the `--all` flag: ``` git cl st --all @@ -526,16 +525,13 @@ Use `git cl stash `, then switch branches and [git cl unstash](#31-stash-a ### Why don’t I see all files in git cl status? -By default, [git cl status](#22-view-status-by-changelist) filters out files with uncommon Git status codes (e.g. merge conflicts or type changes) to keep the output clean. - -If you want to include everything, use the `--all` flag: +By default, [git cl status](#22-view-status-by-changelist) shows the status codes you normally need to act on — common working-directory states plus merge conflicts — while hiding rare codes like [T ] (type change) to keep output clean. +To include every Git status code without filtering, use the `--all` flag: ``` git cl status --all ``` -This will show all files, including those with status codes like `[UU]` (unmerged) or `[T ]` (type change). - ### Can I reuse a changelist name later? Yes. If the changelist was deleted after a stage or commit, you can create a new one with the same name — it's just a label, not a persistent identity. @@ -591,8 +587,9 @@ Yes. Each worktree has its own independent set of changelists — changes made i | `[ D]` | Deletion (unstaged) | File deleted but not yet staged | | `[R ]` | Renamed | File renamed and staged | | `[RM]` | Renamed + Modified | Renamed and then modified before staging | +| `[UU]` | Unmerged (conflict) | Both sides modified; resolve before staging | -To show all codes, including rare ones like `[UU]` (conflicts), use: +To show all codes, including rare ones, use: ``` git cl status --all @@ -602,7 +599,6 @@ git cl status --all | Code | Description | | ----- | ----------------------- | -| `[UU]` | Unmerged (conflict) | | `[T ]` | Type change | #### Color Key @@ -611,6 +607,7 @@ git cl status --all |---------|-------------------------------| | Green | Staged changes (`[M ]`, `[A ]`)| | Red | Unstaged changes (`[ M]`, `[ D]`)| +| Red | Needs attention: unstaged changes (`[ M]`, `[ D]`) or merge conflicts (`[UU]`, `[AA]`, …) | | Magenta | Both staged and unstaged (`[MM]`, `[AM]`)| | Blue | Untracked (`[??]`) |