Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 7 additions & 10 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -526,16 +525,13 @@ Use `git cl stash <name>`, 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.
Expand Down Expand Up @@ -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
Expand All @@ -602,7 +599,6 @@ git cl status --all

| Code | Description |
| ----- | ----------------------- |
| `[UU]` | Unmerged (conflict) |
| `[T ]` | Type change |

#### Color Key
Expand All @@ -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 (`[??]`) |

Expand Down
101 changes: 98 additions & 3 deletions git-cl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <files>")
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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading