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/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 (`[??]`) | diff --git a/git-cl b/git-cl index 2f375a7..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 @@ -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 @@ -645,6 +651,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 +2412,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 +2469,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() @@ -2478,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: """ @@ -2547,6 +2626,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 +2824,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 +2908,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 +2984,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)) 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 diff --git a/tests/test_merge_conflict.py b/tests/test_merge_conflict.py new file mode 100755 index 0000000..d43d987 --- /dev/null +++ b/tests/test_merge_conflict.py @@ -0,0 +1,304 @@ +#!/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: 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 + # ================================================================= + + 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"))