#!python
# -*- coding: utf-8 -*-
#
# BSD 3-Clause License
#
# Copyright (c) 2025, Bjoern Hendrik Fock
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
#    may be used to endorse or promote products derived from this software
#    without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# pylint: disable=invalid-name
# pylint: disable=too-many-lines
#
"""
git-cl: A pre-staging layer for organising changes in Git.

GitHub: https://github.com/BHFock/git-cl
DOI:    https://doi.org/10.5281/zenodo.18722077

git-cl brings changelist support to Git, allowing developers to partition
modified files into named groups before staging or committing. Changelists
persist across sessions in a JSON file inside the .git directory, and
integrate transparently with existing Git workflows.

Features:
- Group files into named changelists (add, remove, status)
- Stage, unstage, commit, or diff by changelist
- Checkout (revert) changelist files to HEAD
- Stash and unstash changelists selectively
- Promote a changelist to a dedicated branch (late-binding branching)

Single file, zero dependencies beyond Python 3.9+ and Git.
Cross-platform: Unix (fcntl) and Windows (msvcrt) file locking.
"""

__version__ = "1.1.3"

import argparse
import datetime
import json
import os
import subprocess
import sys
if sys.platform == "win32":
    import msvcrt
else:
    import fcntl
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Iterator

if sys.version_info < (3, 9):
    print("Error: git-cl requires Python 3.9+", file=sys.stderr)
    sys.exit(1)

# Try to import colorama for colored output of 'git cl st'
# use dummy objects if colorama is not available to avoid NameError
try:
    from colorama import Fore, Style
    COLORAMA_AVAILABLE = True
except ImportError:
    COLORAMA_AVAILABLE = False

    # pylint: disable=too-few-public-methods
    class _DummyColorama:
        BLUE = ""
        GREEN = ""
        MAGENTA = ""
        RED = ""
        RESET_ALL = ""
    Fore = _DummyColorama()
    Style = _DummyColorama()

def _relpath(path, start):
    """Like os.path.relpath, but always returns forward slashes."""
    return os.path.relpath(path, start).replace("\\", "/")

# =============================================================================
# INTERNAL UTILITIES
# =============================================================================

# Note: Some utility functions call CLI command functions like cl_stash and
#       cl_unstash. These are defined later in the module but are called by
#       utility functions.


@contextmanager
def clutil_file_lock(lock_path: Path):
    """
    Acquires an exclusive lock on a file for safe concurrent access.

    Creates a lock file and acquires an exclusive lock to prevent concurrent
    access to shared resources. The lock file is automatically cleaned up
    when the context exits, regardless of whether an exception occurs.

    Args:
        lock_path (Path): Path to the lock file to create and lock.

    Yields:
        None

    Raises:
        OSError: If the lock file cannot be created or locked.

    Platform notes:
        On Unix, ``fcntl.flock`` acquires a whole-file advisory lock that
        blocks indefinitely until the lock becomes available.
        On Windows, ``msvcrt.locking`` locks a single byte (the file is
        empty and used only for mutual exclusion, so one byte suffices).
        Unlike ``fcntl.flock``, ``msvcrt.locking`` with ``LK_LOCK`` retries
        for approximately ten seconds before raising ``OSError``.  This is
        acceptable because changelist operations are fast.

    Example:
        >>> lock_file = Path('.git/cl.lock')
        >>> with clutil_file_lock(lock_file):
        ...     # Perform operations that need exclusive access
        ...     pass
        # Lock file is automatically cleaned up here
    """
    lock_file = None
    try:
        lock_file = open(lock_path, 'w', encoding='utf-8')
        if sys.platform == "win32":
            msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
        else:
            fcntl.flock(lock_file, fcntl.LOCK_EX)
        yield
    finally:
        if lock_file:
            if sys.platform == "win32":
                try:
                    msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
                except OSError:
                    pass
            else:
                fcntl.flock(lock_file, fcntl.LOCK_UN)
            lock_file.close()
            lock_path.unlink(missing_ok=True)


def clutil_should_use_color(args) -> bool:
    """
    Determines whether coloured output should be used in CLI display.

    This function checks multiple conditions to decide if colour formatting
    is appropriate:
    - The --no-color flag is not set.
    - The NO_COLOR environment variable is not set.
    - The output is being sent to a terminal (TTY).
    - The 'colorama' module is available.

    Args:
        args (argparse.Namespace): Parsed command-line arguments, expected to
                                   include the 'no_color' attribute.

    Returns:
        bool: True if coloured output should be enabled, False otherwise.
    """
    no_color_env = bool(os.environ.get('NO_COLOR'))
    is_tty = sys.stdout.isatty()
    return (not (args.no_color or no_color_env or not is_tty) and
            COLORAMA_AVAILABLE)


def clutil_resolve_commit_message(args: argparse.Namespace) -> Optional[str]:
    """
    Resolves the commit message from CLI arguments.

    Args:
        args (argparse.Namespace): Parsed arguments with 'message' or 'file'.

    Returns:
        Optional[str]: The commit message, or None if resolution fails.
    """
    if args.message:
        return args.message

    if args.file:
        try:
            return clutil_read_commit_message_file(args.file)
        except (OSError, ValueError) as error:
            print(f"Error reading commit message file '{args.file}': {error}")
            return None

    print("Error: No commit message provided.")
    return None


def clutil_get_file() -> Path:
    """
    Returns the path to the changelist file inside the Git directory.

    Returns:
        Path: Path to the '.git/cl.json' file.

    Raises:
        SystemExit: If not inside a Git repository.
    """
    try:
        git_dir = subprocess.check_output(
            ["git", "rev-parse", "--git-dir"],
            stderr=subprocess.DEVNULL,
            text=True
        ).strip()
        return Path(git_dir) / "cl.json"
    except subprocess.CalledProcessError:
        print("Error: Not inside a Git repository.")
        print("Please run this command from within a Git repository.")
        sys.exit(1)


def clutil_load() -> dict[str, list[str]]:
    """
    Loads changelist data from the 'cl.json' file.

    Returns:
        dict: A mapping of changelist names to lists of file paths.
    """
    changelist_file = clutil_get_file()
    lock_file = changelist_file.with_suffix('.lock')
    if changelist_file.exists():
        with clutil_file_lock(lock_file):
            try:
                with open(changelist_file, "r", encoding="utf-8") as file_handle:
                    return json.load(file_handle)
            except (json.JSONDecodeError, OSError) as error:
                print(f"Error reading changelists: {error}")
                return {}
    return {}


def clutil_save(data: dict[str, list[str]]) -> None:
    """
    Saves the changelist data to 'cl.json', omitting empty changelists.

    Args:
        data (dict): Mapping of changelist names to lists of files.
    """
    changelist_file = clutil_get_file()
    lock_file = changelist_file.with_suffix('.lock')
    cleaned = {k: v for k, v in data.items() if v}
    with clutil_file_lock(lock_file):
        try:
            with open(changelist_file, "w", encoding="utf-8") as file_handle:
                json.dump(cleaned, file_handle, indent=2)
        except OSError as error:
            print(f"Error saving changelists: {error}")


def clutil_validate_name(name: str) -> bool:
    """
    Validates that a changelist name is safe to use.

    Args:
        name (str): The changelist name to validate.

    Returns:
        bool: True if the name is valid, False otherwise.
    """
    # Check for empty names
    if not name:
        return False

    # Check for reasonable length (max 100 characters)
    if len(name) > 100:
        return False

    # Only allow safe characters: letters, numbers, hyphens, underscores, dots
    allowed_chars = set(
        'abcdefghijklmnopqrstuvwxyz'
        'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
        '0123456789-_.'
    )

    if not all(char in allowed_chars for char in name):
        return False

    # Don't allow names that are only dots (filesystem issues)
    if all(char == '.' for char in name):
        return False

    # Check for Git reserved words (only the critical ones)
    reserved_names = {'HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD',
                      'CHERRY_PICK_HEAD', 'index'}
    if name in reserved_names:
        return False

    return True


def clutil_get_git_root() -> Path:
    """
    Returns the absolute path to the root of the current Git repository.

    Returns:
        Path: The top-level directory of the Git repository.

    Raises:
        SystemExit: If the current directory is not inside a Git repository.
    """
    try:
        return Path(
            subprocess.check_output(
                ["git", "rev-parse", "--show-toplevel"],
                text=True
            ).strip()
        ).resolve()
    except subprocess.CalledProcessError as error:
        print(f"Error: Not inside a Git repository. {error}")
        sys.exit(1)


def clutil_get_git_status(include_untracked: bool = False) -> list[str]:
    """
    Returns the output of 'git status --porcelain' as a list of lines.

    Args:
        include_untracked (bool): If True, includes untracked files using
                                  '--untracked-files=all'.

    Returns:
        list[str]: Each line represents a file's status.

    Raises:
        SystemExit: If the git command fails.
    """
    cmd = ["git", "status", "--porcelain"]
    if include_untracked:
        cmd.append("--untracked-files=all")

    try:
        return subprocess.check_output(cmd, text=True).splitlines()
    except subprocess.CalledProcessError as error:
        print(f"Error getting git status: {error}")
        sys.exit(1)


def clutil_sanitize_path(file_path: str, git_root: Path) -> Optional[str]:
    """
    Sanitizes and validates a file path for safe use with Git commands.

    Args:
        file_path (str): The file path to sanitize
        git_root (Path): The root directory of the Git repository

    Returns:
        str | None: The sanitized path relative to git_root, or None if invalid
    """
    try:
        # Convert to Path object and resolve any relative components
        path = Path(file_path)

        # If it's relative, make it relative to current working directory
        if not path.is_absolute():
            path = Path.cwd().resolve() / path

        # Resolve any .. or . components and get absolute path
        path = path.resolve()

        # Ensure the path is within the Git repository
        try:
            relative_path = path.relative_to(git_root)
        except ValueError:
            # Path is outside the Git repository
            return None

        # Convert back to string using forward slashes (Git standard)
        sanitized = relative_path.as_posix()

        # Basic security check: reject paths with dangerous characters
        dangerous_chars = [';', '|', '&', '`', '$', '\n', '\r', '\0']
        if any(char in sanitized for char in dangerous_chars):
            return None

        return sanitized

    except (OSError, ValueError, RuntimeError):
        # Any path resolution errors
        return None


def clutil_get_file_status_map(show_all: bool = False) -> dict[str, str]:
    """
    Get a mapping of files to their Git 2-letter status codes.

    By default, only a predefined set of common status codes are included.
    Files with uncommon status codes (e.g. merge conflicts, type changes)
    are skipped and counted for a warning message — unless `--all` is used.

    Ignored files (status code '! ') are silently excluded from both the
    output and the warning, since `git status --porcelain` does not include
    them unless explicitly requested with `--ignored`.

    Args:
        show_all (bool): If True, include all files regardless of status code.

    Returns:
        dict[str, str]: Mapping of relative file paths
                        to their Git status codes.
    """
    git_root = clutil_get_git_root()
    output = clutil_get_git_status(include_untracked=True)

    # Allowlist of known meaningful status codes
    INTERESTING_CODES = {
        '??', ' M', 'M ', 'MM', 'A ', 'AM', ' D', 'D ', 'R ', 'RM'
    }

    status_map = {}
    skipped = {}

    for line in output:
        if len(line) < 4:
            continue  # malformed line, ignore
        code = line[:2]
        raw_path = line[3:].strip()

        # Handle renamed files
        if code.startswith('R') and '->' in raw_path:
            raw_path = raw_path.split('->')[-1].strip()

        abs_path = (git_root / raw_path).resolve()
        try:
            rel_path = abs_path.relative_to(git_root).as_posix()
        except ValueError:
            rel_path = raw_path

        if show_all or code in INTERESTING_CODES:
            status_map[rel_path] = code
        else:
            skipped.setdefault(code, []).append(rel_path)

    if skipped and not show_all:
        skipped_count = sum(len(v) for v in skipped.values())
        print(f"Note: {skipped_count} file(s) with uncommon Git status codes "
              "were not shown. Use 'git cl st --all' to include them.")

    return status_map


def clutil_format_file_status(
        file: str, status_map: dict[str, str], git_root: Path,
        use_color: bool = True) -> str:
    """
    Format a file's Git status line with optional color-coded output.
    Paths are shown relative to the current working directory.
    """
    abs_file = (git_root / file).resolve()
    rel_to_cwd = _relpath(abs_file, Path.cwd().resolve())

    rel_to_git_root = abs_file.relative_to(git_root).as_posix()
    status = status_map.get(rel_to_git_root, "  ")

    # Add detection of files which are deleted (committed) but still
    # included in a changelists
    if not abs_file.exists() and status == "  ":
        status = "XX"  # Custom code for missing file
        print(f"Warning: The file '{rel_to_cwd}' does not exist "
              f"on the current branch.\n"
              f"         Remove it from changelist with "
              f"'git cl rm {rel_to_cwd}'.")

    tag = f"[{status}]"

    if not use_color or not COLORAMA_AVAILABLE:
        return f"  {tag} {rel_to_cwd}"

    staged, unstaged = status[0], status[1]

    if status == "??":
        color = Fore.BLUE
    elif staged == 'A':
        color = Fore.GREEN
    elif staged != " " and unstaged == " ":
        color = Fore.GREEN
    elif staged == " " and unstaged != " ":
        color = Fore.RED
    elif staged != " " and unstaged != " ":
        color = Fore.MAGENTA
    else:
        color = Style.RESET_ALL

    return f"{color}  {tag} {rel_to_cwd}{Style.RESET_ALL}"


def clutil_read_commit_message_file(file_path: str) -> str:
    """
    Reads a commit message from a file, with validation and processing.

    Args:
        file_path (str): Path to the file containing the commit message

    Returns:
        str: The commit message content

    Raises:
        OSError: If the file cannot be read
        ValueError: If the file is empty or contains only whitespace
    """
    try:
        # Resolve path relative to current working directory
        path = Path(file_path).resolve()

        # Basic security check - ensure file exists and is readable
        if not path.exists():
            raise OSError(f"File does not exist: {file_path}")

        if not path.is_file():
            raise OSError(f"Path is not a file: {file_path}")

        # Reasonable size limit for commit messages (generous 64KB)
        if path.stat().st_size > 64 * 1024:
            raise ValueError("Commit message file too large (>64KB)")

        # Read the file
        with open(path, 'r', encoding='utf-8') as f:
            content = f.read(64 * 1024)  # Enforce the same limit

    except (OSError, UnicodeDecodeError) as error:
        raise OSError(f"Cannot read file: {error}") from error

    # Validate content
    if not content or not content.strip():
        raise ValueError(
            "Commit message file is empty or contains only whitespace")

    # Process the content similar to how Git does it:
    # - Strip trailing whitespace from each line
    # - Remove trailing empty lines
    lines = [line.rstrip() for line in content.splitlines()]

    # Remove trailing empty lines
    while lines and not lines[-1]:
        lines.pop()

    if not lines:
        raise ValueError(
            "Commit message file contains no content after processing")

    # Join lines back together
    processed_content = '\n'.join(lines)

    return processed_content


def clutil_is_file_untracked(
        file_path_rel_to_git_root: str, git_root: Path) -> bool:
    """
    Check if a file (specified relative to git root) is untracked.

    Args:
        file_path_rel_to_git_root: File path relative to git repository root
        git_root: Absolute path to git repository root

    Returns:
        True if the file is untracked, False if it's tracked
    """
    # Get git status output
    output = clutil_get_git_status(include_untracked=True)

    # Get absolute path of the file we're checking
    abs_file_path = (git_root / file_path_rel_to_git_root).resolve()

    # Check untracked files and directories
    for line in output:
        if line.startswith("??"):
            raw_path = line[3:].strip()
            abs_untracked = (git_root / raw_path).resolve()

            # Direct match for files
            if abs_file_path == abs_untracked:
                return True

            # Check if file is under an untracked directory
            if (
                abs_untracked.is_dir()
                and abs_file_path.is_relative_to(abs_untracked)
            ):
                return True

    return False


def clutil_get_stash_file() -> Path:
    """
    Returns the path to the stash metadata file inside the Git directory.

    Returns:
        Path: Path to the '.git/cl-stashes.json' file.
    """
    git_dir = Path(subprocess.check_output(
        ["git", "rev-parse", "--git-dir"],
        stderr=subprocess.DEVNULL,
        text=True
    ).strip())
    return git_dir / "cl-stashes.json"


def clutil_load_stashes() -> dict[str, dict]:
    """
    Loads stash metadata from the 'cl-stashes.json' file.

    Returns:
        dict: A mapping of stashed changelist names to their metadata.
    """
    stash_file = clutil_get_stash_file()
    lock_file = stash_file.with_suffix('.lock')
    if stash_file.exists():
        with clutil_file_lock(lock_file):
            try:
                with open(stash_file, "r", encoding="utf-8") as file_handle:
                    return json.load(file_handle)
            except (json.JSONDecodeError, OSError) as error:
                print(f"Error reading stash metadata: {error}")
                return {}
    return {}


def clutil_save_stashes(data: dict[str, dict]) -> None:
    """
    Saves the stash metadata to 'cl-stashes.json'.

    Args:
        data (dict): Mapping of stashed changelist names to metadata.
    """
    stash_file = clutil_get_stash_file()
    lock_file = stash_file.with_suffix('.lock')
    with clutil_file_lock(lock_file):
        try:
            with open(stash_file, "w", encoding="utf-8") as file_handle:
                json.dump(data, file_handle, indent=2)
        except OSError as error:
            print(f"Error saving stash metadata: {error}")


def clutil_check_files_unstaged(files: list[str],
                                git_root: Path) -> tuple[list[str], list[str]]:
    """
    Check which files in the list have staged vs unstaged changes.

    Args:
        files: List of file paths relative to git root
        git_root: Path to git repository root

    Returns:
        tuple: (unstaged_files, staged_files)
               files that can/cannot be stashed
    """
    status_map = clutil_get_file_status_map(show_all=True)

    unstaged_files = []
    staged_files = []

    for file_path in files:
        abs_path = (git_root / file_path).resolve()
        if not abs_path.exists():
            continue

        rel_to_git_root = abs_path.relative_to(git_root).as_posix()
        status = status_map.get(rel_to_git_root, "  ")

        # Check if file has staged changes (first character is not space)
        if status[0] != " ":
            staged_files.append(file_path)
        else:
            unstaged_files.append(file_path)

    return unstaged_files, staged_files


# Git status analysis for unstash conflicts
# Maps (staged_char, unstaged_char) -> (is_conflict, description)
UNSTASH_STATUS_ANALYSIS = {
    ('?', '?'): (True, "untracked file blocking stash restoration"),
    (' ', 'M'): (True, "modified in working directory"),
    (' ', 'D'): (True, "deleted in working directory"),
    ('A', ' '): (False, "staged for addition (safe)"),
    ('M', ' '): (False, "staged modifications (safe)"),
    ('D', ' '): (False, "staged for deletion (safe)"),
    ('R', ' '): (False, "staged rename (safe)"),
    (' ', ' '): (False, "clean (ready for unstash)"),
}


def clutil_analyze_file_status_for_unstash(status: str) -> tuple[bool, str]:
    """
    Analyze a Git status code to determine unstash conflicts.

    Args:
        status: 2-character Git status code

    Returns:
        Tuple of (is_conflict, description)
    """
    if len(status) != 2:
        return False, "unknown status"

    staged, unstaged = status[0], status[1]

    # Check direct lookup first
    if (staged, unstaged) in UNSTASH_STATUS_ANALYSIS:
        return UNSTASH_STATUS_ANALYSIS[(staged, unstaged)]

    # Handle remaining cases with simple logic
    if unstaged in ['M', 'D']:
        if unstaged == 'M':
            conflict_desc = "modified in working directory"
        else:
            conflict_desc = "deleted in working directory"
        return True, conflict_desc

    if staged in ['A', 'M', 'D', 'R']:
        stage_desc = {
            'A': "staged for addition (safe)",
            'M': "staged modifications (safe)",
            'D': "staged for deletion (safe)",
            'R': "staged rename (safe)"
        }
        return False, stage_desc.get(staged, "staged changes (safe)")

    return False, "clean (ready for unstash)"


def clutil_check_unstash_conflicts_optimized(
        files: list[str], git_root: Path) -> tuple[list[str], dict[str, str]]:
    """
    Check for conflicts optimized for the stash-branch-unstash workflow.

    In the intended workflow:
    1. Stash changelists → clean working directory
    2. Create/switch to feature branch
    3. Unstash specific changelist → should be conflict-free

    This function focuses on detecting only the conflicts that actually matter
    in this workflow, rather than being overly conservative.

    Args:
        files: List of file paths relative to git root that were stashed
        git_root: Path to git repository root

    Returns:
        tuple: (real_conflicts, file_status_info) where:
        - real_conflicts: Files that will actually prevent unstashing
        - file_status_info: Dict of file -> status description for user info
    """
    status_map = clutil_get_file_status_map(show_all=True)
    real_conflicts = []
    file_status_info = {}

    for file_path in files:
        abs_path = (git_root / file_path).resolve()

        if not abs_path.exists():
            # File doesn't exist - this is actually GOOD for unstashing
            # The stash will restore it without conflict
            file_status_info[file_path] = "missing (will be restored)"
            continue

        rel_to_git_root = abs_path.relative_to(git_root).as_posix()
        status = status_map.get(rel_to_git_root, "  ")

        # Use the lookup table for clean analysis
        is_conflict, description = clutil_analyze_file_status_for_unstash(status)

        if is_conflict:
            real_conflicts.append(file_path)

        file_status_info[file_path] = description

    return real_conflicts, file_status_info


def clutil_suggest_workflow_actions(
        conflicting_files: list[str],
        file_status_info: dict[str, str]) -> None:
    """
    Provide workflow-specific suggestions for resolving conflicts.

    Args:
        conflicting_files: Files that have real conflicts
        file_status_info: Status information for all files
    """
    if not conflicting_files:
        return

    print("Workflow suggestions to resolve conflicts:")

    untracked_conflicts = []
    modified_conflicts = []
    deleted_conflicts = []

    for file_path in conflicting_files:
        status_desc = file_status_info[file_path]
        if "untracked" in status_desc:
            untracked_conflicts.append(file_path)
        elif "modified in working" in status_desc:
            modified_conflicts.append(file_path)
        elif "deleted in working" in status_desc:
            deleted_conflicts.append(file_path)

    if untracked_conflicts:
        print("\n  Untracked files blocking stash restoration:")
        for file_path in untracked_conflicts:
            print(f"    {file_path}")
        print("  → Solution: Move, rename, or delete these untracked files")

    if modified_conflicts:
        print("\n  Working directory modifications:")
        for file_path in modified_conflicts:
            print(f"    {file_path}")
        print("  → Solution: Stage/commit these changes or revert them")
        print(f"    git add {' '.join(modified_conflicts)} "
              "&& git commit -m 'Save current work'")
        print(f"    # OR: git checkout -- {' '.join(modified_conflicts)}")

    if deleted_conflicts:
        print("\n  Files deleted in working directory:")
        for file_path in deleted_conflicts:
            print(f"    {file_path}")
        print("  → Solution: If intentional, stage the deletions;"
              "if not, restore the files")
        print(f"    git rm {' '.join(deleted_conflicts)} "
              "&& git commit -m 'Remove files'")
        print(f"    # OR: git checkout HEAD -- {' '.join(deleted_conflicts)}")


def clutil_get_current_branch() -> Optional[str]:
    """
    Get the name of the current Git branch.

    Returns:
        Branch name or None if not on a branch (detached HEAD)
    """
    try:
        result = subprocess.run(["git", "branch", "--show-current"],
                                capture_output=True, text=True, check=True)
        branch = result.stdout.strip()
        return branch if branch else None
    except subprocess.CalledProcessError:
        return None


@contextmanager
def clutil_safe_stash_context(
        git_root: Path,
        files_for_git: list[str],
        untracked_files_for_git: list[str]) -> Iterator[tuple[list[str],
                                                              list[str]]]:
    """
    Context manager to handle directory changes during stash operations.
    Recalculates file paths relative to git root.

    Args:
        git_root: Path to git repository root
        files_for_git: Original file paths relative to current directory
        untracked_files_for_git: Original untracked file paths
                                 relative to current directory

    Yields:
        tuple[list[str], list[str]]: (files_for_git, untracked_files_for_git)
                                     relative to git root
    """
    original_cwd = Path.cwd().resolve()

    def convert_to_git_root_relative(file_paths: list[str]) -> list[str]:
        """Convert file paths to be relative to git root."""
        result = []
        for file_path in file_paths:
            abs_path = (original_cwd / file_path).resolve()
            try:
                git_root_relative = abs_path.relative_to(git_root)
                result.append(git_root_relative.as_posix())
            except ValueError:
                # Path is outside git root - should not happen with validation
                result.append(file_path)
        return result

    # Convert both file lists
    safe_files = convert_to_git_root_relative(files_for_git)
    safe_untracked_files = convert_to_git_root_relative(untracked_files_for_git)

    # Change to git root
    os.chdir(git_root)

    try:
        yield safe_files, safe_untracked_files
    finally:
        # Try to restore original directory
        if original_cwd.exists():
            os.chdir(original_cwd)
        else:
            # Directory was deleted - warn user and stay in git root
            try:
                rel_path = _relpath(original_cwd, git_root)
                print(f"Note: Directory '{rel_path}' was removed by git stash.")
                print("Your shell is still pointing to the deleted directory.")
                print("Please run: cd ..")
            except (ValueError, OSError):
                print("Note: Your working directory was removed by git stash.")
                print("Your shell is still pointing to the deleted directory.")
                print("Please run: cd ..")


def clutil_get_stash_source_branch(stash_data: dict) -> Optional[str]:
    """
    Extract the branch name from stash metadata if stored.

    Args:
        stash_data: Stash metadata dictionary

    Returns:
        Branch name or None if not available
    """
    return stash_data.get('source_branch')


def clutil_find_stash_by_message(message: str) -> Optional[str]:
    """
    Find a stash reference by its commit message.

    Args:
        message: The stash message to search for

    Returns:
        The stash reference (e.g. "stash@{0}") or None if not found
    """
    try:
        stash_list = subprocess.check_output(["git", "stash", "list"],
                                             text=True)
        for line in stash_list.strip().split('\n'):
            if line and message in line:
                return line.split(':')[0]
    except subprocess.CalledProcessError:
        pass
    return None


def clutil_create_unique_stash_message(changelist_name: str) -> str:
    """
    Create a unique stash message that includes changelist name and timestamp.
    This helps identify our stashes even when mixed with user-created stashes.

    Args:
        changelist_name: Name of the changelist being stashed

    Returns:
        Unique stash message string
    """
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    return f"git-cl-stash:{changelist_name}:{timestamp}"


def clutil_parse_stash_message(
        stash_message: str) -> Optional[tuple[str, str]]:
    """
    Parse our stash message format to extract changelist name and timestamp.

    Args:
        stash_message: The stash message to parse

    Returns:
        Tuple of (changelist_name, timestamp) or None if not our format
    """
    if stash_message.startswith("git-cl-stash:"):
        try:
            parts = stash_message.split(":")
            if len(parts) >= 3:
                changelist_name = parts[1]
                timestamp = parts[2]
                return changelist_name, timestamp
        except (IndexError, ValueError):
            pass
    return None


def clutil_find_stash_by_message_substring(
        message_substring: str) -> Optional[str]:
    """
    Find a stash reference by searching for a substring in stash messages.

    Args:
        message_substring: Part of the stash message to search for

    Returns:
        The stash reference (e.g. "stash@{2}") or None if not found
    """
    try:
        stash_list = subprocess.check_output(["git", "stash", "list"],
                                             text=True)
        for line in stash_list.strip().split('\n'):
            if line and message_substring in line:
                # Extract stash reference from format:
                # "stash@{0}: WIP on main: abc1234 message"
                stash_ref = line.split(':')[0].strip()
                return stash_ref
    except subprocess.CalledProcessError:
        pass
    return None


def clutil_rollback_stash(stash_ref: str, changelist_name: str) -> bool:
    """
    Attempt to rollback a stash creation by dropping the stash.

    Args:
        stash_ref: The stash reference to drop (e.g. "stash@{0}")
        changelist_name: Name of changelist for error reporting

    Returns:
        True if rollback succeeded, False otherwise
    """
    try:
        # Attempt to drop the stash
        subprocess.run(["git", "stash", "drop", stash_ref],
                       check=True, capture_output=True, text=True)
        print(f"Rolled back stash {stash_ref} due to metadata save failure.")
        return True
    except subprocess.CalledProcessError as error:
        print(f"Warning: Could not rollback stash {stash_ref}: {error}")
        print(f"You may need to manually clean up stash for changelist "
              f"'{changelist_name}'")
        return False


def clutil_stash_all_changelists(changelists: dict[str, list[str]],
                                 quiet: bool = False) -> None:
    """
    Helper function to stash all active changelists.

    Args:
        changelists: Dictionary of active changelists
        quiet: If True, suppress verbose output for workflow contexts
    """
    if not changelists:
        if not quiet:
            print("No active changelists found.")
        return

    # Get current branch for tracking
    current_branch = clutil_get_current_branch()

    if not quiet:
        print(f"Stashing all active changelists from branch "
              f"'{current_branch or 'detached HEAD'}':")

    success_count = 0

    # Sort changelists by name for predictable order
    sorted_changelists = sorted(changelists.items())

    for cl_name, files in sorted_changelists:
        if not files:  # Skip empty changelists
            continue

        if not quiet:
            print(f"\n--- Stashing '{cl_name}' ---")

        # Create a temporary args object for individual stash
        temp_args = argparse.Namespace()
        temp_args.name = cl_name
        temp_args.all = False

        try:
            # Call cl_stash with quiet mode
            cl_stash(temp_args, quiet=quiet)
            success_count += 1
        except (OSError, subprocess.CalledProcessError,
                json.JSONDecodeError) as error:
            print(f"Failed to stash '{cl_name}': {error}")
            continue

    if not quiet:
        print(
            f"\nStashed {success_count}/"
            f"{len([cl for cl, files in sorted_changelists if files])} "
            "changelists successfully."
        )
        if success_count > 0:
            print("Working directory should now be clean for branch operations.")
            print("Use 'git cl unstash <changelist>' to restore individual "
                  "changelists to feature branches.")


def clutil_validate_stash_preconditions(
    name: str,
    changelists: dict[str, list[str]],
    stashes: dict[str, dict]
) -> Optional[str]:
    """
    Validate preconditions for stashing a changelist.

    Args:
        name: Changelist name
        changelists: Active changelists
        stashes: Already stashed changelists

    Returns:
        Error message if validation fails, None if all checks pass
    """
    # Check if changelist exists
    if name not in changelists:
        return f"Changelist '{name}' not found."

    # Check if changelist is already stashed
    if name in stashes:
        return (f"Changelist '{name}' is already stashed.\n"
                f"Use 'git cl unstash {name}' to restore it first.")

    # Check if changelist is empty
    if not changelists[name]:
        return f"Changelist '{name}' is empty - nothing to stash."

    return None


def clutil_categorize_files_for_stash(
    files: list[tuple[str, Path]],
    missing_files: list[str],
    status_map: dict[str, str],
    git_root: Path
) -> tuple[dict[str, list[str]], list[str], list[str]]:
    """
    Categorize files based on their Git status for stashing.

    Args:
        files: List of (stored_path, abs_path) tuples for existing files
        missing_files: List of paths for non-existent files
        status_map: Git status mapping
        git_root: Git repository root

    Returns:
        Tuple of (file_categories, stashable_files, unstashable_files)
    """
    file_categories = {
        'unstaged_changes': [],     # Modified, deleted in working directory
        'staged_additions': [],     # Newly added to index (A_)
        'untracked': [],            # Untracked files (??)
        'deleted_files': [],        # Files that don't exist (potential deletions)
        'clean_files': [],          # Clean files (not stashable)
        'staged_modifications': []  # Files with only staged changes (not stashable)
    }

    stashable_files = []
    unstashable_files = []

    # Process existing files
    for stored_path, abs_path in files:
        rel_to_git_root = abs_path.relative_to(git_root).as_posix()
        status = status_map.get(rel_to_git_root, "  ")
        staged, unstaged = status[0], status[1]

        if status == "??":
            # Untracked files
            # - stashable since they're explicitly in changelist
            file_categories['untracked'].append(stored_path)
            stashable_files.append(stored_path)
        elif unstaged in ['M', 'D']:
            # Unstaged modifications/deletions - stashable
            file_categories['unstaged_changes'].append(stored_path)
            stashable_files.append(stored_path)
        elif staged == 'A' and unstaged == ' ':
            # Newly added to index - stashable
            file_categories['staged_additions'].append(stored_path)
            stashable_files.append(stored_path)
        elif staged in ['M', 'D', 'R'] and unstaged == ' ':
            # Only staged changes - not stashable with git stash push
            file_categories['staged_modifications'].append(stored_path)
            unstashable_files.append(stored_path)
        elif status == "  ":
            # Clean files - not stashable
            file_categories['clean_files'].append(stored_path)
            unstashable_files.append(stored_path)
        else:
            # Mixed staged+unstaged or other complex states
            if unstaged != ' ':
                # Has unstaged component - stashable
                file_categories['unstaged_changes'].append(stored_path)
                stashable_files.append(stored_path)
            else:
                # Only staged component - not stashable
                file_categories['staged_modifications'].append(stored_path)
                unstashable_files.append(stored_path)

    # Process missing files (potential deletions)
    for stored_path in missing_files:
        # Check if this is a deletion by looking at git status
        rel_to_git_root = stored_path  # already relative to git root
        status = status_map.get(rel_to_git_root, "  ")

        if status[1] == 'D':  # Deleted in working directory
            file_categories['deleted_files'].append(stored_path)
            stashable_files.append(stored_path)
        else:
            # File just doesn't exist and git doesn't know about it
            print(f"Warning: File '{stored_path}' "
                  f"does not exist and is not tracked.")
            unstashable_files.append(stored_path)

    return file_categories, stashable_files, unstashable_files


def clutil_report_stash_categorization(
    file_categories: dict[str, list[str]],
    unstashable_files: list[str],
    name: str,
    quiet: bool = False
) -> bool:
    """
    Report file categorization results and check if stash can proceed.

    Args:
        file_categories: Categorized files
        unstashable_files: List of unstashable files
        name: Changelist name
        quiet: If True, suppress verbose output

    Returns:
        True if stash can proceed, False otherwise
    """
    # Report what we found (unless in quiet mode)
    if not quiet:
        if file_categories['unstaged_changes']:
            print(f"Files with unstaged changes: "
                  f"{len(file_categories['unstaged_changes'])}")
        if file_categories['staged_additions']:
            print(f"Newly added files: "
                  f"{len(file_categories['staged_additions'])}")
        if file_categories['untracked']:
            print(f"Untracked files: {len(file_categories['untracked'])}")
        if file_categories['deleted_files']:
            print(f"Deleted files: {len(file_categories['deleted_files'])}")

    # Check for unstashable files and report (always show errors)
    if unstashable_files:
        print(f"\nError: Cannot stash changelist '{name}' "
              f"because these files are not stashable:")

        if file_categories['clean_files']:
            print("  Clean files (no changes):")
            for file_path in file_categories['clean_files']:
                print(f"    {file_path}")

        if file_categories['staged_modifications']:
            print("  Files with only staged changes "
                  "(use 'git stash --staged' manually if needed):")
            for file_path in file_categories['staged_modifications']:
                print(f"    {file_path}")

        other_unstashable = [f for f in unstashable_files
                             if f not in file_categories['clean_files']
                             and f not in file_categories['staged_modifications']]
        if other_unstashable:
            print("  Other non-stashable files:")
            for file_path in other_unstashable:
                print(f"    {file_path}")

        print("\nStashable files must have:")
        print("  - Unstaged changes (modified/deleted)")
        print("  - Be newly added to index")
        print("  - Be untracked (if explicitly in changelist)")
        return False

    return True


def clutil_prepare_files_for_git_stash(
    stashable_files: list[str],
    file_categories: dict[str, list[str]],
    git_root: Path,
    quiet: bool = False
) -> tuple[list[str], list[str]]:
    """
    Prepare file paths for git stash command.

    Args:
        stashable_files: List of files to stash
        file_categories: Categorized files
        git_root: Git repository root
        quiet: If True, suppress verbose output

    Returns:
        Tuple of (files_for_git, untracked_files_for_git)
    """
    files_for_git = []
    for stored_path in stashable_files:
        abs_path = (git_root / stored_path).resolve()
        if abs_path.exists():
            rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
            files_for_git.append(rel_to_cwd)
        else:
            # For deleted files, we still need to pass the path
            rel_to_cwd = _relpath(git_root / stored_path, Path.cwd().resolve())
            files_for_git.append(rel_to_cwd)

    # Handle untracked files specially
    # - they need to be added to git first for stashing
    untracked_files_for_git = []
    if file_categories['untracked']:
        if not quiet:
            print("Adding untracked files to git temporarily for stashing...")
        for stored_path in file_categories['untracked']:
            abs_path = (git_root / stored_path).resolve()
            rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
            untracked_files_for_git.append(rel_to_cwd)

        try:
            # Add untracked files to git index
            subprocess.run(["git", "add", "--"] + untracked_files_for_git,
                           check=True)
        except subprocess.CalledProcessError as error:
            print(f"Error adding untracked files: {error}")
            raise

    return files_for_git, untracked_files_for_git


def clutil_execute_git_stash(
    name: str,
    files_for_git: list[str],
    untracked_files_for_git: list[str]
) -> str:
    """
    Execute the git stash command.

    Args:
        name: Changelist name
        files_for_git: Files to stash
        untracked_files_for_git: Untracked files that were temporarily added

    Returns:
        The stash reference if successful

    Raises:
        subprocess.CalledProcessError: If stash fails
    """
    stash_message = clutil_create_unique_stash_message(name)

    try:
        cmd = ["git", "stash", "push", "-m", stash_message, "--"] + files_for_git
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)

        # Check if anything was actually stashed
        if "No local changes to save" in result.stderr:
            print(f"Unexpected: No changes were stashed for changelist '{name}'.")
            # If we added untracked files, we should reset them
            if untracked_files_for_git:
                try:
                    subprocess.run(["git", "reset", "HEAD", "--"] +
                                   untracked_files_for_git, check=True)
                    print("Reset temporarily added untracked files.")
                except subprocess.CalledProcessError:
                    print("Warning: Could not reset temporarily added untracked files.")
            raise ValueError("No changes were stashed")

    except subprocess.CalledProcessError as error:
        print(f"Error creating stash: {error}")
        if error.stderr:
            print(f"Git error: {error.stderr}")

        # Cleanup: Reset any untracked files we added
        if untracked_files_for_git:
            try:
                subprocess.run(["git", "reset", "HEAD", "--"] + untracked_files_for_git,
                               check=True)
                print("Reset temporarily added untracked files after stash failure.")
            except subprocess.CalledProcessError:
                print("Warning: Could not reset temporarily added untracked files.")
        raise

    # Find the created stash
    stash_ref = clutil_find_stash_by_message_substring(f"git-cl-stash:{name}:")
    if not stash_ref:
        print("Error: Could not locate the created stash.")
        print(f"Look for a stash with message containing 'git-cl-stash:{name}:'")
        raise ValueError("Could not locate stash")

    return stash_ref


def clutil_build_stash_metadata(
    name: str,
    stash_ref: str,
    files: list[str],
    file_categories: dict[str, list[str]],
    current_branch: Optional[str]
) -> dict:
    """
    Build stash metadata dictionary.

    Args:
        name: Changelist name
        stash_ref: Git stash reference
        files: Files in the changelist
        file_categories: Categorized files
        current_branch: Current Git branch

    Returns:
        Dictionary containing stash metadata
    """
    stash_message = clutil_create_unique_stash_message(name)

    return {
        "stash_ref": stash_ref,
        "stash_message": stash_message,
        "files": files.copy(),
        "timestamp": datetime.datetime.now().isoformat(),
        "source_branch": current_branch,
        "file_categories": {
            "unstaged_changes": file_categories['unstaged_changes'],
            "staged_additions": file_categories['staged_additions'],
            "untracked": file_categories['untracked'],
            "deleted_files": file_categories['deleted_files']
        }
    }


# pylint: disable-next=too-many-arguments
def clutil_save_stash_metadata_atomic(
    name: str,
    stash_ref: str,
    files: list[str],
    file_categories: dict[str, list[str]],
    changelists: dict[str, list[str]],
    stashes: dict[str, dict]
) -> None:
    """
    Atomically save stash metadata and update changelists.

    Args:
        name: Changelist name
        stash_ref: Git stash reference
        files: Files in the changelist
        file_categories: Categorized files
        changelists: Active changelists (will be modified)
        stashes: Stashed changelists (will be modified)

    Raises:
        Exception: If save operations fail (triggers rollback)
    """
    current_branch = clutil_get_current_branch()

    # Build stash metadata
    stash_metadata = clutil_build_stash_metadata(
        name, stash_ref, files, file_categories, current_branch
    )

    # Save stash metadata
    stashes[name] = stash_metadata
    clutil_save_stashes(stashes)

    # Remove changelist from active changelists
    del changelists[name]
    clutil_save(changelists)


def clutil_handle_stash_failure(
    error: Exception,
    name: str,
    stash_ref: str,
    original_changelists: dict[str, list[str]],
    stashes: dict[str, dict]
) -> None:
    """
    Handle failure during stash metadata save with rollback.

    Args:
        error: The exception that occurred
        name: Changelist name
        stash_ref: Git stash reference
        original_changelists: Original changelists for rollback
        stashes: Stashed changelists dictionary
    """
    print(f"Error during atomic operation: {error}")
    print("Attempting to rollback changes...")

    rollback_success = True

    # Try to restore stashes metadata (remove the failed entry)
    try:
        if name in stashes:
            del stashes[name]
            clutil_save_stashes(stashes)
            print("Rolled back stash metadata.")
        else:
            print("Stash metadata was not saved, no rollback needed.")

    except (OSError, subprocess.CalledProcessError,
            json.JSONDecodeError) as stash_rollback_error:
        print(f"Warning: Could not rollback stash metadata: {stash_rollback_error}")
        rollback_success = False

    # Try to restore original changelists
    try:
        clutil_save(original_changelists)
        print("Rolled back changelist modifications.")

    except (OSError, subprocess.CalledProcessError,
            json.JSONDecodeError) as changelist_rollback_error:
        print(f"Warning: Could not rollback changelist changes: "
              f"{changelist_rollback_error}")
        rollback_success = False

    # Try to drop the orphaned stash
    if not clutil_rollback_stash(stash_ref, name):
        rollback_success = False

    if rollback_success:
        print(f"Rollback completed successfully. "
              f"Changelist '{name}' remains active.")
    else:
        print("Partial rollback failure. Manual cleanup may be required:")
        print(f"  - Check 'git stash list' for orphaned stash: {stash_ref}")
        print(f"  - Verify changelist '{name}' state with 'git cl status'")
        print(f"  - Consider 'git stash drop {stash_ref}' if stash is orphaned")


def clutil_unstash_all_changelists(
    stashes: dict[str, dict],
    force: bool = False
) -> None:
    """
    Helper function to unstash all stashed changelists.

    Args:
        stashes: Dictionary of stashed changelists
        force: Whether to force unstash despite conflicts
    """
    if not stashes:
        print("No stashed changelists found.")
        return

    current_branch = clutil_get_current_branch()
    if not current_branch:
        print("Error: Cannot unstash --all while in detached HEAD state.")
        print("Switch to a branch first: git checkout -b <branch-name>")
        return

    print(f"Unstashing all changelists to branch '{current_branch}':")
    success_count = 0

    # Sort by timestamp (oldest first) for predictable order
    stash_items = list(stashes.items())
    stash_items.sort(key=lambda x: x[1].get('timestamp', ''))

    for stash_name, _ in stash_items:
        print(f"\n--- Unstashing '{stash_name}' ---")
        # Create a temporary args object for individual unstash
        temp_args = argparse.Namespace()
        temp_args.name = stash_name
        temp_args.force = force
        temp_args.all = False

        try:
            # Note: cl_unstash is called later in the module
            cl_unstash(temp_args)
            success_count += 1
        except (OSError,
                subprocess.CalledProcessError, json.JSONDecodeError) as error:
            print(f"Failed to unstash '{stash_name}': {error}")
            continue

    print(f"\nUnstashed {success_count}/{len(stash_items)} "
          f"changelists successfully.")


def clutil_resolve_unstash_name(name: str) -> tuple[str, str]:
    """
    Resolve changelist name handling both "name" and "name_stashed" formats.

    Args:
        name: The changelist name (with or without _stashed suffix)

    Returns:
        Tuple of (base_name, stash_key)
    """
    if name.endswith("_stashed"):
        base_name = name[:-8]  # Remove "_stashed" suffix
        stash_key = base_name
    else:
        base_name = name
        stash_key = name

    return base_name, stash_key


def clutil_validate_unstash_preconditions(
    base_name: str,
    stash_key: str,
    stashes: dict[str, dict],
    changelists: dict[str, list[str]]
) -> Optional[str]:
    """
    Validate preconditions for unstashing a changelist.

    Args:
        base_name: The base changelist name
        stash_key: The key in stashes dictionary
        stashes: Dictionary of stashed changelists
        changelists: Dictionary of active changelists

    Returns:
        Error message if validation fails, None if all checks pass
    """
    if stash_key not in stashes:
        error_msg = f"No stashed changelist found for '{base_name}'."
        available = list(stashes.keys())
        if available:
            error_msg += "\nAvailable stashed changelists:"
            for stashed_name in available:
                error_msg += f"\n  {stashed_name}"
        return error_msg

    # Check if target changelist name already exists
    if base_name in changelists and changelists[base_name]:
        return (f"Error: Changelist '{base_name}' already exists with files.\n"
                "Please delete or rename the existing changelist first.")

    return None


def clutil_check_branch_workflow(
    base_name: str,
    source_branch: Optional[str],
    force: bool
) -> Optional[str]:
    """
    Check branch workflow requirements for unstashing.

    Args:
        base_name: The changelist name
        source_branch: The branch where changelist was stashed from
        force: Whether to force unstash despite warnings

    Returns:
        Error message if check fails, None if checks pass
    """
    if force:
        return None

    current_branch = clutil_get_current_branch()

    if not current_branch:
        return ("Error: Cannot unstash while in detached HEAD state.\n"
                "Create a new branch first: git checkout -b feature-branch\n"
                f"Or use --force to unstash anyway: git cl unstash {base_name} --force")

    # Allow restoring to original branch, but encourage new branches
    if source_branch and current_branch == source_branch:
        print(f"Note: Unstashing '{base_name}' back to original branch "
              f"'{current_branch}'")
        print("This is allowed but consider using a new branch for feature work.")
    else:
        # Check if working directory is clean (recommended for branch workflow)
        try:
            git_status = subprocess.run(["git", "status", "--porcelain"],
                                        capture_output=True, text=True, check=True)
            if git_status.stdout.strip():
                return (f"Warning: Working directory is not clean on branch '{current_branch}'\n"
                        "The branch workflow works best with a clean working directory.\n"
                        "Consider committing or stashing current changes first.\n"
                        "Or use --force to unstash anyway: git cl unstash "
                        f"{base_name} --force")
        except subprocess.CalledProcessError:
            pass  # git status failed, continue anyway

    return None


def clutil_check_and_report_conflicts(
    files: list[str],
    git_root: Path,
    base_name: str,
    force: bool,
    quiet: bool = False
) -> bool:
    """
    Check for conflicts and report them to user.

    Args:
        files: List of files to unstash
        git_root: Git repository root
        base_name: Changelist name
        force: Whether to force despite conflicts
        quiet: If True, suppress verbose output

    Returns:
        True if unstash can proceed, False otherwise
    """
    if force:
        return True

    real_conflicts, file_status_info = (
        clutil_check_unstash_conflicts_optimized(files, git_root))

    if real_conflicts:
        print(f"Cannot unstash '{base_name}' due to conflicts:")
        for file_path in real_conflicts:
            status_desc = file_status_info[file_path]
            print(f"  {file_path} ({status_desc})")

        clutil_suggest_workflow_actions(real_conflicts, file_status_info)
        print(f"\nAfter resolving conflicts, retry: git cl unstash {base_name}")
        print(f"Or use --force to attempt automatic merge:"
              f"git cl unstash {base_name} --force")
        return False

    # Show informational status for non-conflicting files (unless quiet)
    if not quiet:
        safe_files = [f for f in files if f not in real_conflicts]
        if safe_files:
            staged_files = []
            clean_files = []
            missing_files = []
            for file_path in safe_files:
                status_desc = file_status_info.get(file_path, "")
                if "staged" in status_desc:
                    staged_files.append(file_path)
                elif "clean" in status_desc:
                    clean_files.append(file_path)
                elif "missing" in status_desc:
                    missing_files.append(file_path)

            status_summary = []
            if clean_files:
                status_summary.append(f"{len(clean_files)} clean")
            if missing_files:
                status_summary.append(f"{len(missing_files)} to be restored")
            if staged_files:
                status_summary.append(f"{len(staged_files)} with staged changes")

            if status_summary:
                print(f"Ready to unstash {len(safe_files)} "
                      f"files: {', '.join(status_summary)}")

    return True


def clutil_verify_and_update_stash_ref(
    base_name: str,
    old_stash_ref: str
) -> Optional[str]:
    """
    Verify stash still exists and update reference if needed.

    Checks if the stash for the given changelist still exists and updates
    the stash reference if it has changed (due to other stash operations).
    The updated reference is persisted to the stash metadata file to prevent
    future reference drift.

    Args:
        base_name: Changelist name
        old_stash_ref: Previously stored stash reference

    Returns:
        Updated stash reference or None if stash doesn't exist
    """
    current_stash_ref = clutil_find_stash_by_message_substring(f"git-cl-stash:"
                                                               f"{base_name}:")

    if not current_stash_ref:
        print(f"Error: Stash for changelist '{base_name}' no longer exists.")
        print("The stash may have been manually dropped. Available stashes:")
        try:
            stash_list = subprocess.check_output(["git", "stash", "list"],
                                                 text=True)
            if stash_list.strip():
                for line in stash_list.strip().split('\n'):
                    print(f"  {line}")
            else:
                print("  (no stashes found)")
        except subprocess.CalledProcessError:
            print("  (could not list stashes)")
        return None

    # Update stash reference if needed
    if current_stash_ref != old_stash_ref:
        print(f"Note: Stash reference updated from {old_stash_ref} "
              f"to {current_stash_ref}")

        # Persist the updated reference to prevent future drift
        stashes = clutil_load_stashes()
        if base_name in stashes:
            stashes[base_name]["stash_ref"] = current_stash_ref
            clutil_save_stashes(stashes)

    return current_stash_ref


def clutil_apply_stash(stash_ref: str, base_name: str, quiet: bool = False) -> bool:
    """
    Apply the stash using git stash pop.

    Args:
        stash_ref: Git stash reference
        base_name: Changelist name for error messages
        quiet: If True, suppress verbose output

    Returns:
        True if successful, False if conflicts occurred
    """
    if not quiet:
        current_branch = clutil_get_current_branch()
        branch_info = f" to branch '{current_branch}'" if current_branch else ""
        print(f"Unstashing changelist '{base_name}'{branch_info}...")

    try:
        result = subprocess.run(["git", "stash", "pop", stash_ref],
                                capture_output=True, text=True, check=True)

        # Handle merge conflicts
        if "CONFLICT" in result.stdout or "CONFLICT" in result.stderr:
            print(f"Merge conflicts occurred while unstashing '{base_name}'.")
            print("This is unexpected in the typical "
                  "stash-branch-unstash workflow.")
            print("\nTo resolve:")
            print("  1. Resolve conflicts in the affected files")
            print("  2. Stage resolved files: git add <resolved-files>")
            print(f"  3. Recreate changelist: git cl add {base_name} <files>")
            return False

        return True

    except subprocess.CalledProcessError as error:
        print(f"Error unstashing '{base_name}': {error}")
        if "CONFLICT" in str(error.stderr):
            print("Merge conflicts occurred. To resolve:")
            print("  1. Check 'git status' for conflicted files")
            print("  2. Resolve conflicts and stage files")
            print("  3. Recreate changelist manually")
        else:
            print("This may indicate the working directory is not clean.")
            print("Ensure you're on a clean branch before unstashing.")
        return False


def clutil_process_unstash_result(
    base_name: str,
    files: list[str],
    current_branch: Optional[str]
) -> None:
    """
    Process and display results after successful unstash.

    Args:
        base_name: Changelist name
        files: List of files that were restored
        current_branch: Current Git branch
    """
    success_msg = f"  Successfully unstashed changelist '{base_name}'"
    if current_branch:
        success_msg += f" to branch '{current_branch}'"
    print(success_msg)
    print(f"  Restored {len(files)} files to working directory")
    print("  Ready to work on your feature!")

    # Show the files that were restored
    if len(files) <= 10:
        print("Files restored:")
        for file_path in files:
            print(f"  {file_path}")
    else:
        print(f"Files restored: {files[0]}, {files[1]}, "
              f"... and {len(files) - 2} others")


def clutil_update_unstash_metadata(
    base_name: str,
    stash_key: str,
    files: list[str],
    changelists: dict[str, list[str]],
    stashes: dict[str, dict],
    quiet: bool = False
) -> None:
    """
    Update metadata after successful unstash.

    Args:
        base_name: Changelist name
        stash_key: Key in stashes dictionary
        files: List of files in the changelist
        changelists: Active changelists dictionary (will be modified)
        stashes: Stashed changelists dictionary (will be modified)
        quiet: If True, suppress verbose output
    """
    try:
        changelists[base_name] = files.copy()
        clutil_save(changelists)

        del stashes[stash_key]
        clutil_save_stashes(stashes)

        if not quiet:
            current_branch = clutil_get_current_branch()
            clutil_process_unstash_result(base_name, files, current_branch)

    except (OSError, subprocess.CalledProcessError,
            json.JSONDecodeError) as metadata_error:
        print(f"Warning: Unstash succeeded but metadata update failed: "
              f"{metadata_error}")
        print(f"Manually recreate the changelist: git cl add "
              f"{base_name} <files>")


def clutil_prepare_stash_files(
    files: list[str],
    git_root: Path
) -> tuple[list[tuple[str, Path]], list[str]]:
    """
    Prepare files for stashing by checking existence.

    Args:
        files: List of file paths from changelist
        git_root: Git repository root

    Returns:
        Tuple of (existing_files, missing_files)
    """
    missing_files = []
    existing_files = []

    for stored_path in files:
        abs_path = (git_root / stored_path).resolve()
        if not abs_path.exists():
            missing_files.append(stored_path)
        else:
            existing_files.append((stored_path, abs_path))

    return existing_files, missing_files


def clutil_print_stash_success(
    name: str,
    stashable_files: list[str],
    file_categories: dict[str, list[str]],
    current_branch: Optional[str]
) -> None:
    """
    Print success message after stashing.

    Args:
        name: Changelist name
        stashable_files: List of files that were stashed
        file_categories: Categorized files
        current_branch: Current Git branch
    """
    success_msg = f"  Successfully stashed changelist '{name}'"
    if current_branch:
        success_msg += f" from branch '{current_branch}'"
    print(success_msg)
    print(f"  Stashed {len(stashable_files)} file(s):")

    # Show breakdown of what was stashed
    categories_to_show = {
        'unstaged_changes': 'unstaged changes',
        'staged_additions': 'newly added',
        'untracked': 'untracked',
        'deleted_files': 'deleted'
    }

    for category_key, category_display in categories_to_show.items():
        files_list = file_categories.get(category_key, [])
        if files_list:
            print(f"    {len(files_list)} with {category_display}")

    print("  Working directory is now clean for this changelist")


def clutil_show_active_changelists(
    changelists: dict[str, list[str]],
    selected_names: Optional[set[str]],
    status_map: dict[str, str],
    git_root: Path,
    use_color: bool
) -> tuple[bool, set[str]]:
    """
    Display active changelists and return assigned files.

    Args:
        changelists: Active changelists
        selected_names: Set of names to filter by (None = show all)
        status_map: Git status mapping
        git_root: Git repository root
        use_color: Whether to use colored output

    Returns:
        Tuple of (shown_any, assigned_files)
    """
    assigned_files = set()
    shown_any = False

    for cl_name, files in changelists.items():
        # Skip any remaining stashed entries (cleanup from old implementation)
        if cl_name.endswith('_stashed'):
            continue

        if selected_names is not None and cl_name not in selected_names:
            continue

        if files:  # Only show non-empty changelists
            print(f"{cl_name}:")
            shown_any = True
            for file in files:
                print(clutil_format_file_status(file, status_map, git_root, use_color))

            # Track assigned files
            for f in files:
                try:
                    abs_path = (git_root / f).resolve()
                    rel_path = abs_path.relative_to(git_root).as_posix()
                    assigned_files.add(rel_path)
                except (ValueError, OSError) as error:
                    print(f"Error resolving path: {error}")

    return shown_any, assigned_files


def clutil_show_stashed_changelists(stashes: dict[str, dict]) -> None:
    """
    Display stashed changelists section.

    Args:
        stashes: Dictionary of stashed changelists
    """
    print("Stashed Changelists:")

    # Sort by timestamp (most recent first)
    stashed_items = []
    for stash_name, stash_data in stashes.items():
        timestamp = stash_data.get('timestamp', '')
        file_count = len(stash_data.get('files', []))
        stashed_items.append((stash_name, file_count, timestamp))

    # Sort by timestamp descending (most recent first)
    stashed_items.sort(key=lambda x: x[2], reverse=True)

    for stash_name, file_count, timestamp in stashed_items:
        # Format timestamp for display
        try:
            dt = datetime.datetime.fromisoformat(timestamp)
            formatted_time = dt.strftime("%Y-%m-%d %H:%M")
        except (ValueError, AttributeError):
            formatted_time = "unknown time"

        file_word = "file" if file_count == 1 else "files"
        print(f"  {stash_name} ({file_count} {file_word}, {formatted_time})")


def clutil_validate_unstash_environment(
    base_name: str,
    stash_key: str,
    stashes: dict[str, dict],
    changelists: dict[str, list[str]],
    force: bool
) -> tuple[Optional[str], Optional[dict]]:
    """
    Validate all preconditions for unstashing.

    Args:
        base_name: Base changelist name
        stash_key: Key in stashes dictionary
        stashes: Stashed changelists
        changelists: Active changelists
        force: Whether to force despite warnings

    Returns:
        Tuple of (error_message, stash_data) - error_message is None if valid
    """
    # Validate basic preconditions
    error_msg = clutil_validate_unstash_preconditions(
        base_name, stash_key, stashes, changelists
    )
    if error_msg:
        return error_msg, None

    stash_data = stashes[stash_key]
    source_branch = clutil_get_stash_source_branch(stash_data)

    # Check branch workflow requirements
    error_msg = clutil_check_branch_workflow(base_name, source_branch, force)
    if error_msg:
        return error_msg, None

    return None, stash_data


# =============================================================================
# BRANCH WORKFLOW UTILITIES (branch-specific utilities)
# =============================================================================

def clutil_validate_branch_preconditions(
        changelist_name: str, changelists: dict, stashes: dict) -> str:
    """Validate preconditions for branch creation workflow."""
    if changelist_name not in changelists:
        error_msg = f"Error: Changelist '{changelist_name}' not found."
        available = [name for name in changelists.keys() if changelists[name]]
        if available:
            error_msg += "\nAvailable active changelists:"
            for name in available:
                error_msg += f"\n  {name}"
        return error_msg

    if not changelists[changelist_name]:
        return (f"Error: Changelist '{changelist_name}' is empty.\n"
                f"Add files with: git cl add {changelist_name} <files>")

    if changelist_name in stashes:
        return (f"Error: Changelist '{changelist_name}' is already stashed.\n"
                f"Use 'git cl unstash {changelist_name}' to restore it first.")

    return ""


def clutil_check_branch_exists(branch_name: str) -> bool:
    """Check if a branch already exists."""
    try:
        result = subprocess.run(["git", "show-ref", "--verify",
                                 f"refs/heads/{branch_name}"],
                                capture_output=True, text=True, check=False)
        return result.returncode == 0
    except subprocess.CalledProcessError:
        return False


def clutil_check_unassigned_changes(changelists: dict,
                                    git_root: Path) -> list[str]:
    """Check for uncommitted changes not in any changelist."""
    status_map = clutil_get_file_status_map(show_all=True)

    # Get all files assigned to changelists
    assigned_files = set()
    for files in changelists.values():
        for file_path in files:
            try:
                abs_path = (git_root / file_path).resolve()
                rel_path = abs_path.relative_to(git_root).as_posix()
                assigned_files.add(rel_path)
            except (ValueError, OSError):
                continue

    # Check for unassigned changes (ignore untracked files)
    return [file_path for file_path, status in status_map.items()
            if file_path not in assigned_files and status != "??"]


def clutil_execute_stash_all() -> None:
    """Execute stash all operation."""
    temp_args = argparse.Namespace()
    temp_args.all = True
    cl_stash(temp_args)


def clutil_create_branch(branch_name: str,
                         base_branch: str, current_branch: str) -> None:
    """Create new branch from base."""
    if base_branch != current_branch:
        # Create branch from specified base
        subprocess.run(["git", "checkout", "-b", branch_name, base_branch],
                       check=True)
    else:
        # Create branch from current HEAD
        subprocess.run(["git", "checkout", "-b", branch_name], check=True)


def clutil_unstash_changelist(changelist_name: str) -> None:
    """Unstash specific changelist."""
    temp_unstash_args = argparse.Namespace()
    temp_unstash_args.name = changelist_name
    temp_unstash_args.force = False
    temp_unstash_args.all = False
    cl_unstash(temp_unstash_args, quiet=True)  # Add quiet=True here


def clutil_handle_branch_creation_failure() -> None:
    """Handle failure during branch creation by restoring stashes."""
    try:
        temp_unstash_args = argparse.Namespace()
        temp_unstash_args.all = True
        temp_unstash_args.force = True
        cl_unstash(temp_unstash_args)
    except (OSError, subprocess.CalledProcessError,
            json.JSONDecodeError) as restore_error:
        print(f"Error restoring stashes: {restore_error}")
        print("You may need to manually unstash: git cl unstash --all")


# =============================================================================
# CLI COMMANDS
# =============================================================================


def cl_add(args: argparse.Namespace) -> None:
    """
    Adds one or more files to the specified changelist, creating it if needed.
    Prevents adding files that are in stashed changelists.
    """
    if not clutil_validate_name(args.name):
        print(
            f"Error: Invalid changelist name '{args.name}'. Names cannot "
            "contain special characters or be Git reserved words.")
        return

    changelists = clutil_load()
    stashes = clutil_load_stashes()
    git_root = clutil_get_git_root()

    files = []
    for file in args.files:
        sanitized = clutil_sanitize_path(file, git_root)
        if sanitized:
            # Check if file exists and warn if not
            abs_path = (git_root / sanitized).resolve()
            if not abs_path.exists():
                print(f"Warning: File '{file}' does not exist.")
            files.append(sanitized)
        else:
            print(f"Warning: Skipping invalid or unsafe path: '{file}'")

    # Check if any files are in stashed changelists
    blocked_files = []
    for file_path in files:
        for stash_name, stash_data in stashes.items():
            if file_path in stash_data['files']:
                blocked_files.append((file_path, stash_name))

    if blocked_files:
        print("Error: Cannot add files that are in stashed changelists:")
        for file_path, stash_name in blocked_files:
            print(f"  {file_path} (in stashed changelist '{stash_name}')")
        print("Unstash the changelist first if you want to modify it.")
        return

    if args.name not in changelists:
        changelists[args.name] = []

    for file in files:
        # Remove from other active changelists
        for clist in changelists.values():
            if file in clist:
                clist.remove(file)
        if file not in changelists[args.name]:
            changelists[args.name].append(file)

    clutil_save(changelists)
    print(f"Added to '{args.name}': {files}")


def cl_stage(args: argparse.Namespace) -> None:
    """
    Stages all tracked files in the given changelist and optionally
    deletes the changelist.

    Args:
        args: argparse.Namespace with 'name' and 'delete' attributes.
    """
    changelists = clutil_load()
    name = args.name
    if name not in changelists:
        print(f"Changelist '{name}' not found.")
        return

    git_root = clutil_get_git_root()
    to_stage = []

    for stored_path in changelists[name]:
        # Convert stored path (relative to git root) to absolute path
        abs_path = (git_root / stored_path).resolve()

        if not abs_path.exists():
            print(f"Warning: '{stored_path}' does not exist.")
            continue

        # Convert to path relative to current working directory for Git command
        rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())

        # Check if this file is tracked (not untracked)
        if not clutil_is_file_untracked(stored_path, git_root):
            to_stage.append(rel_to_cwd)

    if not to_stage:
        print(f"No tracked files to stage in changelist '{name}'.")
        return

    try:
        subprocess.run(["git", "add", "--"] + to_stage, check=True)
        print(f"Staged tracked files from changelist '{name}':")
        for file in to_stage:
            print(f"  {file}")
    except subprocess.CalledProcessError as error:
        print(f"Error staging files: {error}")
        return

    # Only delete if --delete flag is set
    if args.delete:
        del changelists[name]
        clutil_save(changelists)
        print(f"Deleted changelist '{name}'")


def cl_unstage(args: argparse.Namespace) -> None:
    """
    Unstages all staged files in the specified changelist, moving them back
    to the working directory as unstaged changes.

    Args:
        args: argparse.Namespace with 'name' and 'keep' attributes.
    """
    changelists = clutil_load()
    name = args.name
    if name not in changelists:
        print(f"Changelist '{name}' not found.")
        return

    git_root = clutil_get_git_root()
    to_unstage = []

    # Get current status to identify staged files
    status_map = clutil_get_file_status_map(show_all=True)

    for stored_path in changelists[name]:
        # Convert stored path (relative to git root) to absolute path
        abs_path = (git_root / stored_path).resolve()

        if not abs_path.exists():
            print(f"Warning: '{stored_path}' does not exist.")
            continue

        # Convert to path relative to current working directory for Git command
        rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())

        # Check if this file has staged changes
        rel_to_git_root = abs_path.relative_to(git_root).as_posix()
        status = status_map.get(rel_to_git_root, "  ")

        # File has staged changes if first character is not space
        if status[0] != " ":
            to_unstage.append(rel_to_cwd)

    if not to_unstage:
        print(f"No staged files to unstage in changelist '{name}'.")
        return

    try:
        # Use git reset HEAD to unstage files
        subprocess.run(["git", "reset", "HEAD", "--"] + to_unstage, check=True)
        print(f"Unstaged files from changelist '{name}':")
        for file in to_unstage:
            print(f"  {file}")
    except subprocess.CalledProcessError as error:
        print(f"Error unstaging files: {error}")
        return

    # Only delete if --delete flag is set
    if args.delete:
        del changelists[name]
        clutil_save(changelists)
        print(f"Deleted changelist '{name}'")


def cl_status(args: argparse.Namespace) -> None:
    """
    Displays git status grouped by changelist membership,
    including stashed changelists.

    Shows active changelists first, then a separate "Stashed Changelists" section,
    then unassigned files. Filtering by changelist names applies only to active
    changelists unless --include-no-cl is specified.
    """
    selected_names = set(args.names) if args.names else None
    changelists = clutil_load()
    stashes = clutil_load_stashes()
    git_root = clutil_get_git_root()
    status_map = clutil_get_file_status_map(show_all=args.all)

    use_color = clutil_should_use_color(args)

    # Show active changelists
    shown_any_active, assigned_files = clutil_show_active_changelists(
        changelists, selected_names, status_map, git_root, use_color
    )

    # Show stashed changelists section
    if stashes and (selected_names is None):  # Only show when not filtering
        if shown_any_active:
            print()  # Add spacing
        clutil_show_stashed_changelists(stashes)

    # Show unassigned files
    if selected_names is None or args.include_no_cl:
        no_cl_files = [file for file in status_map if file not in assigned_files]
        if no_cl_files:
            if shown_any_active or stashes:
                print()  # Add spacing
            print("No Changelist:")
            for file in sorted(no_cl_files):
                print(clutil_format_file_status(file, status_map, git_root, use_color))


def cl_diff(args: argparse.Namespace) -> None:
    """
    Show git diff for one or more changelists.

    By default, shows unstaged changes (like `git diff`).
    Use --staged for staged changes, or --both to show both.

    Args:
        args: argparse.Namespace with 'names' list of changelists,
              and optional 'staged' and 'both' flags
    """
    changelists = clutil_load()
    git_root = clutil_get_git_root()
    diff_files = set()

    for name in args.names:
        if name not in changelists:
            print(f"Changelist '{name}' not found.")
            continue
        for path in changelists[name]:
            abs_path = (git_root / path).resolve()
            if abs_path.exists():
                rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
                diff_files.add(rel_to_cwd)
            else:
                print(f"Warning: File '{path}' from '{name}' does not exist.")

    if not diff_files:
        print("No valid files to diff.")
        return

    sorted_files = sorted(diff_files)

    try:
        if args.both:
            # Show both unstaged and staged diffs
            print("=== Unstaged changes (working tree vs index) ===")
            subprocess.run(["git", "diff", "--"] + sorted_files, check=False)
            print("\n=== Staged changes (index vs HEAD) ===")
            subprocess.run(["git", "diff", "--staged", "--"] + sorted_files,
                           check=False)
        elif args.staged:
            # Show only staged changes
            subprocess.run(["git", "diff", "--staged", "--"] + sorted_files,
                           check=False)
        else:
            # Default: show unstaged changes
            subprocess.run(["git", "diff", "--"] + sorted_files, check=False)
    except subprocess.CalledProcessError as error:
        print(f"Error running git diff: {error}")


def cl_checkout(args: argparse.Namespace) -> None:
    """
    Checkout (revert) files from one or more changelists to their HEAD state.

    This discards local changes in the working directory for the specified files,
    reverting them to their last committed state. Useful for undoing unwanted
    local modifications. Optionally deletes the changelist(s) after checkout.

    Args:
        args: argparse.Namespace with 'names' list of changelist names,
              optional 'force' flag for confirmation, and optional 'delete'
              flag to remove changelist(s) after checkout.
    """
    changelists = clutil_load()
    git_root = clutil_get_git_root()

    # Collect all files from specified changelists
    all_files = []
    missing_changelists = []

    for name in args.names:
        if name not in changelists:
            missing_changelists.append(name)
            continue

        files = changelists[name]
        if not files:
            print(f"Warning: Changelist '{name}' is empty.")
            continue

        all_files.extend(files)

    # Report missing changelists
    if missing_changelists:
        print("Error: The following changelists were not found:")
        for name in missing_changelists:
            print(f"  {name}")
        if not all_files:  # No valid changelists found
            return

    if not all_files:
        print("No files to checkout.")
        return

    # Convert to paths relative to current working directory for git command
    checkout_files = []
    missing_files = []

    for stored_path in all_files:
        abs_path = (git_root / stored_path).resolve()

        if not abs_path.exists():
            missing_files.append(stored_path)
            continue

        rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
        checkout_files.append(rel_to_cwd)

    # Report missing files
    if missing_files:
        print("Warning: The following files do not exist and will be skipped:")
        for file_path in missing_files:
            print(f"  {file_path}")

    if not checkout_files:
        print("No existing files to checkout.")
        return

    # Show what will be affected and ask for confirmation unless --force
    print(f"This will discard local changes in {len(checkout_files)} file(s):")
    for file_path in checkout_files[:10]:  # Show first 10 files
        print(f"  {file_path}")
    if len(checkout_files) > 10:
        print(f"  ... and {len(checkout_files) - 10} more files")

    if not args.force:
        try:
            response = input("\nProceed? [y/N]: ").strip().lower()
            if response not in ('y', 'yes'):
                print("Checkout cancelled.")
                return
        except (EOFError, KeyboardInterrupt):
            print("\nCheckout cancelled.")
            return

    # Perform the checkout
    try:
        subprocess.run(["git", "checkout", "HEAD", "--"] + checkout_files,
                       check=True)
        print(f"Successfully reverted {len(checkout_files)} file(s) to HEAD state.")

        # Show summary by changelist
        print("\nFiles reverted by changelist:")
        for name in args.names:
            if name in changelists and changelists[name]:
                reverted_count = sum(1 for f in changelists[name]
                                     if f in all_files and
                                     (git_root / f).resolve().exists())
                if reverted_count > 0:
                    print(f"  {name}: {reverted_count} files")

        # Delete changelists if --delete flag is set
        if args.delete:
            deleted_any = False
            for name in args.names:
                if name in changelists:
                    del changelists[name]
                    print(f"Deleted changelist '{name}'")
                    deleted_any = True

            if deleted_any:
                clutil_save(changelists)

    except subprocess.CalledProcessError as error:
        print(f"Error during checkout: {error}")
        print("Some files may not have been reverted.")


def cl_remove(args: argparse.Namespace) -> None:
    """
    Removes one or more files from any changelists they are part of.

    Args:
        args: argparse.Namespace with 'files' attribute.
    """
    changelists = clutil_load()
    removed = []

    git_root = clutil_get_git_root()
    for file in args.files:
        sanitized = clutil_sanitize_path(file, git_root)
        if sanitized is None:
            print(f"Warning: Skipping invalid or unsafe path: '{file}'")
            continue

        found = False
        for name, files in changelists.items():
            if sanitized in files:
                files.remove(sanitized)
                removed.append(sanitized)
                print(f"Removed '{sanitized}' from changelist '{name}'")
                found = True
                break  # Stop after first match

        if not found:
            print(f"'{file}' was not found in any changelist.")

    if removed:
        clutil_save(changelists)


def cl_delete(args: argparse.Namespace) -> None:
    """
    Deletes one or more changelists by name, or all with --all.
    """
    changelists = clutil_load()

    if args.all and args.names:
        print("Error: Cannot specify changelist names and --all together.")
        return

    if args.all:
        if not changelists:
            print("No changelists to delete.")
            return
        count = len(changelists)
        changelists.clear()
        clutil_save(changelists)
        print(f"Deleted all {count} changelists.")
        return

    if not args.names:
        print("Error: No changelist names provided.")
        return

    deleted = False
    for name in args.names:
        if name in changelists:
            del changelists[name]
            print(f"Deleted changelist '{name}'")
            deleted = True
        else:
            print(f"Changelist '{name}' not found.")

    if deleted:
        clutil_save(changelists)


def cl_commit(args: argparse.Namespace) -> None:
    """
    Commits all tracked files in the specified changelist, then optionally
    deletes it.

    Args:
        args: argparse.Namespace with 'name', 'message'/'file',
              and 'keep' attributes.
    """
    changelists = clutil_load()
    name = args.name
    if name not in changelists:
        print(f"Changelist '{name}' not found.")
        return

    git_root = clutil_get_git_root()
    to_commit = []

    for stored_path in changelists[name]:
        # Convert stored path (relative to git root) to absolute path
        abs_path = (git_root / stored_path).resolve()

        if not abs_path.exists():
            print(f"Warning: '{stored_path}' does not exist.")
            continue

        # Convert to path relative to current working directory for Git command
        rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())

        # Check if this file is tracked (not untracked)
        if not clutil_is_file_untracked(stored_path, git_root):
            to_commit.append(rel_to_cwd)

    if not to_commit:
        print(f"No tracked files to commit in changelist '{name}'.")
        return

    commit_message = clutil_resolve_commit_message(args)
    if not commit_message:
        return

    try:
        subprocess.run(["git", "commit", "-m", commit_message, "--"]
                       + to_commit, check=True)
        print(f"Committed tracked files from changelist '{name}':")
        for file in to_commit:
            print(f"  {file}")
    except subprocess.CalledProcessError as error:
        print(f"Error committing changelist '{name}': {error}")
        return

    if not args.keep:
        del changelists[name]
        clutil_save(changelists)
        print(f"Deleted changelist '{name}'")
    else:
        print(f"Kept changelist '{name}'")


def cl_stash(args: argparse.Namespace, quiet: bool = False) -> None:
    """
    Stash all unstaged changes for files in specified changelist(s).
    Supports --all to stash all active changelists.
    Tracks source branch for better workflow support.

    Accepts files with:
    - Unstaged changes (modified, deleted)
    - Newly added to index (staged additions)
    - Untracked files (if explicitly in changelist)

    Args:
        args: argparse.Namespace with 'name'
              (or None for --all), 'all' attributes.
        quiet: If True, suppress verbose output for workflow contexts
    """
    changelists = clutil_load()
    stashes = clutil_load_stashes()

    # Handle --all flag
    if getattr(args, 'all', False):
        clutil_stash_all_changelists(changelists, quiet=quiet)
        return

    # Handle single changelist stash
    name = args.name

    # Validate preconditions
    error_msg = clutil_validate_stash_preconditions(name, changelists, stashes)
    if error_msg:
        print(error_msg)
        return

    git_root = clutil_get_git_root()
    files = changelists[name]

    # Check that files exist and categorize them
    existing_files, missing_files = clutil_prepare_stash_files(files, git_root)

    # Get file statuses and categorize
    status_map = clutil_get_file_status_map(show_all=True)
    categorization = clutil_categorize_files_for_stash(
        existing_files, missing_files, status_map, git_root
    )
    file_categories, stashable_files, unstashable_files = categorization

    # Report categorization and check if we can proceed (suppress in quiet mode)
    if not clutil_report_stash_categorization(file_categories,
                                              unstashable_files, name, quiet=quiet):
        return

    if not stashable_files:
        if not quiet:
            print(f"No stashable files found in changelist '{name}'.")
        return

    # Prepare files for git stash command
    current_branch = clutil_get_current_branch()
    if not quiet:
        if current_branch:
            branch_info = f" from branch '{current_branch}'"
        else:
            branch_info = " from detached HEAD"
        print(f"\nStashing {len(stashable_files)} file(s) "
              f"from changelist '{name}'{branch_info}...")

    files_for_git, untracked_files_for_git = clutil_prepare_files_for_git_stash(
        stashable_files, file_categories, git_root, quiet=quiet
    )

    # Execute git stash with safe directory handling
    try:
        with clutil_safe_stash_context(
                git_root, files_for_git,
                untracked_files_for_git) as (safe_files, safe_untracked_files):
            stash_ref = clutil_execute_git_stash(name, safe_files,
                                                 safe_untracked_files)
    except (subprocess.CalledProcessError, ValueError):
        return

    # Save metadata atomically
    original_changelists = changelists.copy()

    try:
        clutil_save_stash_metadata_atomic(
            name, stash_ref, files, file_categories, changelists, stashes
        )

        # Success! Print appropriate message based on context
        if not quiet:
            clutil_print_stash_success(name, stashable_files,
                                       file_categories, current_branch)

    except (OSError, subprocess.CalledProcessError,
            json.JSONDecodeError) as error:
        clutil_handle_stash_failure(error, name, stash_ref,
                                    original_changelists, stashes)


def cl_unstash(args: argparse.Namespace, quiet: bool = False) -> None:
    """
    Restore a stashed changelist to the working directory.
    Enforces clean branch workflow by default.
    Supports --all to unstash all stashed changelists.

    Args:
        args: argparse.Namespace with 'name' (or None for --all),
            'force', and 'all' attributes.
        quiet: If True, suppress verbose output for workflow contexts
    """
    stashes = clutil_load_stashes()
    changelists = clutil_load()

    # Handle --all flag
    if getattr(args, 'all', False):
        clutil_unstash_all_changelists(stashes, getattr(args, 'force', False))
        return

    # Handle single changelist unstash
    name = args.name
    base_name, stash_key = clutil_resolve_unstash_name(name)

    # Validate environment and get stash data
    error_msg, stash_data = clutil_validate_unstash_environment(
        base_name, stash_key, stashes, changelists,
        getattr(args, 'force', False)
    )
    if error_msg:
        print(error_msg)
        return

    stash_ref = stash_data["stash_ref"]
    files = stash_data["files"]
    git_root = clutil_get_git_root()

    # Check for conflicts (suppress verbose output in quiet mode)
    if not clutil_check_and_report_conflicts(files, git_root, base_name,
                                             getattr(args, 'force', False),
                                             quiet=quiet):
        return

    # Verify stash still exists and update reference
    stash_ref = clutil_verify_and_update_stash_ref(base_name, stash_ref)
    if not stash_ref:
        return

    # Apply the stash (suppress verbose output in quiet mode)
    if not clutil_apply_stash(stash_ref, base_name, quiet=quiet):
        return

    # Success - update metadata (suppress verbose output in quiet mode)
    clutil_update_unstash_metadata(base_name, stash_key,
                                   files, changelists, stashes, quiet=quiet)


def cl_branch(args: argparse.Namespace) -> None:
    """
    Create a new branch with changelist workflow: stash all changelists,
    create branch, unstash specified changelist.

    This automates the common workflow of:
    1. Stash all active changelists to clean working directory
    2. Create a new branch from current HEAD (or specified base)
    3. Unstash the specified changelist to the new branch

    Args:
        args: argparse.Namespace with 'changelist_name', optional 'branch_name',
              and optional 'from_branch' attributes.
    """
    changelists = clutil_load()
    stashes = clutil_load_stashes()

    changelist_name = args.changelist_name
    branch_name = getattr(args, 'branch_name', None) or changelist_name

    # Validate changelist exists and is active
    error_msg = clutil_validate_branch_preconditions(changelist_name,
                                                     changelists, stashes)
    if error_msg:
        print(error_msg)
        return

    # Check if branch already exists
    if clutil_check_branch_exists(branch_name):
        print(f"Error: Branch '{branch_name}' already exists.")
        print(f"Use a different branch name: git cl br "
              f"{changelist_name} <new-branch-name>")
        return

    # Check for uncommitted changes not in any changelist
    git_root = clutil_get_git_root()
    unassigned_changes = clutil_check_unassigned_changes(changelists, git_root)

    if unassigned_changes:
        print("Error: There are uncommitted changes not in any changelist:")
        for file_path in unassigned_changes[:10]:  # Show first 10
            status_map = clutil_get_file_status_map(show_all=True)
            print(f"  [{status_map[file_path]}] {file_path}")
        if len(unassigned_changes) > 10:
            print(f"  ... and {len(unassigned_changes) - 10} more files")
        print("\nPlease assign these files to changelists first:")
        print(f"  git cl add <changelist-name> {' '.join(unassigned_changes[:3])}")
        return

    # Get current branch for messaging
    current_branch = clutil_get_current_branch()
    if not current_branch:
        print("Error: Cannot create branch from detached HEAD state.")
        print("Switch to a branch first: git checkout <branch-name>")
        return

    # Determine base branch
    base_branch = getattr(args, 'from_branch', None) or current_branch

    # Count active changelists for summary
    active_changelists = {name: files for name, files in changelists.items() if files}

    print(f"Creating branch '{branch_name}' with changelist '{changelist_name}'")
    if len(active_changelists) > 1:
        print(f"Stashing {len(active_changelists)} active changelists...")

    # Step 1: Stash all active changelists (quietly)
    try:
        clutil_stash_all_changelists(active_changelists, quiet=True)
    except (subprocess.CalledProcessError,
            OSError, json.JSONDecodeError) as error:
        print(f"Error during stash operation: {error}")
        print("Branch creation aborted.")
        return

    # Step 2: Create the new branch
    try:
        clutil_create_branch(branch_name, base_branch, current_branch)
        print(f"Switched to new branch '{branch_name}'")
    except subprocess.CalledProcessError as error:
        print(f"Error creating branch: {error}")
        print("Attempting to restore stashed changelists...")
        clutil_handle_branch_creation_failure()
        return

    # Step 3: Unstash the target changelist (quietly)
    try:
        clutil_unstash_changelist(changelist_name)

        # Single success message
        file_count = len(changelists.get(changelist_name, []))
        print(f"Restored changelist '{changelist_name}' ({file_count} files)")
        print("Ready to work on your feature!")

    except (subprocess.CalledProcessError,
            OSError, json.JSONDecodeError) as error:
        print(f"Error unstashing changelist: {error}")
        print(f"Branch '{branch_name}' was created but"
              "changelist restore failed.")
        print(f"Try manually: git cl unstash {changelist_name}")


def cl_help(args: argparse.Namespace) -> None:
    """
    Displays the main help message using argparse's built-in formatting.
    """
    args.parser.print_help()


# =============================================================================
# MAIN ENTRY POINT
# =============================================================================


def main() -> None:
    """
    Entry point for the git-cl command-line interface.

    Parses command-line arguments and dispatches to the appropriate
    subcommand handler (e.g., add, remove, stage, commit, etc.).
    """

    parser = argparse.ArgumentParser(
        prog='git-cl',
        usage='git cl <command> [<args>]',
        description=(
            "git-cl: A Git subcommand for managing named changelists \n"
            "        Group files into logical changelists before staging "
            "or committing.\n\n"
            "        NOTE: Commit/stage commands only operate on tracked files"
            "\n              untracked files in changelists are safely "
            "ignored."),
        epilog=(
            "Example usage:\n"
            "    git cl add my-feature file1.py file2.py\n"
            "    git cl status\n"
            "    git cl commit my-feature -m 'Implement feature'\n\n"
            "See also: \n"
            "    GitHub: https://github.com/BHFock/git-cl \n"
            "    DOI:    https://doi.org/10.5281/zenodo.18722077"),
        formatter_class=argparse.RawTextHelpFormatter,
        add_help=False)

    parser.add_argument(
        '--version',
        action='version',
        version=f'git-cl {__version__}',
        help=argparse.SUPPRESS)

    subparsers = parser.add_subparsers(dest='command', title='Commands')

    # ADD
    add_parser = subparsers.add_parser('add', aliases=['a'],
                                       help='Add files to a named changelist',
                                       description=(
                                           "Add one or more files to a named "
                                           "changelist. If the changelist "
                                           "does not exist, it will be "
                                           "created automatically."))
    add_parser.add_argument('name', metavar='CHANGELIST',
                            help='Name of the changelist to add files to')
    add_parser.add_argument('files', metavar='FILE', nargs='+',
                            help=("One or more files to include "
                                  "in the changelist"))
    add_parser.set_defaults(func=cl_add)

    # REMOVE
    remove_parser = subparsers.add_parser('remove', aliases=['r', 'rm'],
                                          help=("Remove files from any "
                                                "changelist"),
                                          description=("Removes files from "
                                                       "any changelists they "
                                                       "belong to."))
    remove_parser.add_argument('files', metavar='FILE', nargs='+',
                               help=("One or more files to remove "
                                     "from changelists"))
    remove_parser.set_defaults(func=cl_remove)

    # DELETE
    delete_parser = subparsers.add_parser('delete', aliases=['del'],
                                          help=("Delete one or more "
                                                "changelists"),
                                          description=(
                                              "Delete one or more changelists "
                                              "by name, or remove all with "
                                              "--all."))
    delete_parser.add_argument('names', metavar='CHANGELIST', nargs='*',
                               help=("One or more changelists to delete "
                                     "(required unless --all is used)"))
    delete_parser.add_argument('--all', action='store_true',
                               help='Delete all changelists')
    delete_parser.set_defaults(func=cl_delete)

    # STATUS
    status_parser = subparsers.add_parser('status', aliases=['s', 'st'],
                                          help=("Show file changes grouped by "
                                                "changelist"),
                                          description=(
                                              "Show file changes grouped by "
                                              "changelist. Unassigned files "
                                              "appear under 'No Changelist'."))
    status_parser.add_argument('names', metavar='CHANGELIST', nargs='*',
                               help=("Optional list of changelists to show "
                                     "(default: all)"))
    status_parser.add_argument('--include-no-cl', action='store_true',
                               help=("When filtering by changelist names, "
                                     "also show files not assigned to any "
                                     "changelist"))
    status_parser.add_argument('--all', action='store_true',
                               help=("Include files with uncommon Git "
                                     "status codes"))
    status_parser.add_argument('--no-color', action='store_true',
                               help='Disable colored output')
    status_parser.set_defaults(func=cl_status)

    # DIFF
    diff_parser = subparsers.add_parser('diff',
                                        help=("Show git diff for one or more "
                                              "changelists"),
                                        description=(
                                            "Show unified diff for the files "
                                            "in one or more changelists. "
                                            "By default, shows unstaged "
                                            "changes (like `git diff`). "
                                            "Use --staged for staged changes, "
                                            "or --both to show both."))
    diff_parser.add_argument('names', metavar='CHANGELIST', nargs='+',
                             help='One or more changelists to diff')
    diff_parser.add_argument('--staged', action='store_true',
                             help='Show staged changes (index vs HEAD)')
    diff_parser.add_argument('--both', action='store_true',
                             help='Show both unstaged and staged diffs')
    diff_parser.set_defaults(func=cl_diff)

    # CHECKOUT
    checkout_parser = subparsers.add_parser('checkout', aliases=['co'],
                                            help=("Checkout (revert) files "
                                                  "from changelists"),
                                            description=(
                                                "Revert files in one or more "
                                                "changelists to their HEAD "
                                                "state, discarding both "
                                                "staged and unstaged changes "
                                                "in those files. This "
                                                "completely restores files "
                                                "to their last committed "
                                                "state. By default, asks for "
                                                " confirmation before "
                                                "proceeding."))
    checkout_parser.add_argument('names', metavar='CHANGELIST', nargs='+',
                                 help='One or more changelists to checkout')
    checkout_parser.add_argument('--delete', action='store_true',
                                 help='Delete the changelist after checkout')
    checkout_parser.add_argument('--force', '-f', action='store_true',
                                 help='Skip confirmation prompt')
    checkout_parser.set_defaults(func=cl_checkout)

    # STAGE
    stage_parser = subparsers.add_parser('stage',
                                         help=("Stage tracked files from a "
                                               "changelist"),
                                         description=(
                                             "Stage all tracked files from "
                                             "the specified changelist. Only "
                                             "files already tracked by Git "
                                             "will be staged. Untracked files "
                                             "in the changelist are safely "
                                             "ignored and remain untracked. "
                                             "By default, the changelist is "
                                             "kept unless --delete is used."))
    stage_parser.add_argument('name', metavar='CHANGELIST',
                              help='Name of the changelist to stage')
    stage_parser.add_argument('--delete', action='store_true',
                              help='Delete the changelist after staging')
    stage_parser.set_defaults(func=cl_stage)

    # UNSTAGE
    unstage_parser = subparsers.add_parser('unstage',
                                           help=("Unstage tracked files from "
                                                 "a changelist"),
                                           description=(
                                               "Unstage all staged files from "
                                               "the specified changelist, "
                                               "moving them back to unstaged "
                                               "state in the working "
                                               "directory. By default, the "
                                               "changelist is kept unless "
                                               "--delete is used."))
    unstage_parser.add_argument('name', metavar='CHANGELIST',
                                help='Name of the changelist to unstage')
    unstage_parser.add_argument('--delete', action='store_true',
                                help='Delete the changelist after unstaging')
    unstage_parser.set_defaults(func=cl_unstage)

    # COMMIT
    commit_parser = subparsers.add_parser('commit', aliases=['ci'],
                                          help=("Commit tracked files from a "
                                                "changelist (auto-stages, "
                                                "ignores untracked)"),
                                          description=(
                                              "Stage and commit all tracked "
                                              "files from the specified "
                                              "changelist using the provided "
                                              "message. Untracked files in "
                                              "the changelist are ignored and "
                                              "remain untracked. By default, "
                                              "the changelist is deleted "
                                              "after committing unless --keep "
                                              "is used."))
    commit_parser.add_argument('name', metavar='CHANGELIST',
                               help='Name of the changelist to commit')
    msg_group = commit_parser.add_mutually_exclusive_group(required=True)
    msg_group.add_argument('-m', '--message',
                           help='Commit message')
    msg_group.add_argument('-F', '--file',
                           help='Read commit message from file')
    commit_parser.add_argument('--keep', action='store_true',
                               help='Keep the changelist after committing')
    commit_parser.set_defaults(func=cl_commit)

    # STASH
    stash_parser = subparsers.add_parser('stash', aliases=['sh'],
                                         help='Stash changes in changelist(s)',
                                         description=(
                                             "Stash all unstaged changes for "
                                             "files in the specified "
                                             "changelist. Only files with "
                                             "unstaged changes can be "
                                             "stashed. Use --all to stash "
                                             "all active changelists at once "
                                             "for multi-feature branch "
                                             "workflow."))
    stash_group = stash_parser.add_mutually_exclusive_group(required=True)
    stash_group.add_argument('name', nargs='?', metavar='CHANGELIST',
                             help='Name of the changelist to stash')
    stash_group.add_argument('--all', action='store_true',
                             help='Stash all active changelists')
    stash_parser.set_defaults(func=cl_stash)

    # UNSTASH
    unstash_parser = subparsers.add_parser('unstash', aliases=['us'],
                                           help=("Restore stashed "
                                                 "changelist(s)"),
                                           description=(
                                               "Restore a previously stashed "
                                               "changelist to the working "
                                               "directory. Enforces clean "
                                               "branch workflow by default "
                                               "(use --force to override). "
                                               "Use --all to unstash all "
                                               "stashed changelists to the "
                                               "current branch."))
    unstash_group = unstash_parser.add_mutually_exclusive_group(required=True)
    unstash_group.add_argument('name', nargs='?', metavar='CHANGELIST',
                               help=('Name of the changelist to unstash '
                                     '(with or without _stashed suffix)'))
    unstash_group.add_argument('--all', action='store_true',
                               help='Unstash all stashed changelists')
    unstash_parser.add_argument('--force', action='store_true',
                                help=("Force unstash even if branch/"
                                      "conflicts detected"))
    unstash_parser.set_defaults(func=cl_unstash)

    # BRANCH
    branch_parser = subparsers.add_parser('branch', aliases=['br'],
                                          help=("Create branch from "
                                                "changelist workflow"),
                                          description=(
                                              "Automate the changelist-to-"
                                              "branch workflow: stash all "
                                              "active changelists, create a "
                                              "new branch, and unstash the "
                                              "specified changelist to the "
                                              "new branch. This provides a "
                                              "clean way to move changelist "
                                              "work to a dedicated "
                                              "feature branch."))
    branch_parser.add_argument('changelist_name', metavar='CHANGELIST',
                               help=("Name of the changelist to "
                                     "create branch for"))
    branch_parser.add_argument('branch_name',
                               metavar='BRANCH', nargs='?',
                               help=("Name of the new branch "
                                     "(defaults to changelist name)"))
    branch_parser.add_argument('--from', dest='from_branch',
                               metavar='BASE_BRANCH',
                               help=("Create branch from specified base "
                                     "branch (default: current branch)"))
    branch_parser.set_defaults(func=cl_branch)

    # HELP
    help_parser = subparsers.add_parser('help',
                                        help='Show this help message',
                                        description=(
                                            "Displays a summary of "
                                            "available git-cl commands."))
    help_parser.set_defaults(func=cl_help, parser=parser)

    args = parser.parse_args()

    # If no command was provided, show help
    if args.command is None:
        parser.print_help()
        return

    # Provide parser to all commands in case needed
    if not hasattr(args, 'parser'):
        args.parser = parser

    args.func(args)


if __name__ == '__main__':
    main()
