git-changelists 1.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3289 @@
1
+ #!python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # BSD 3-Clause License
5
+ #
6
+ # Copyright (c) 2025, Bjoern Hendrik Fock
7
+ # All rights reserved.
8
+ #
9
+ # Redistribution and use in source and binary forms, with or without
10
+ # modification, are permitted provided that the following conditions are met:
11
+ #
12
+ # 1. Redistributions of source code must retain the above copyright notice,
13
+ # this list of conditions and the following disclaimer.
14
+ #
15
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
16
+ # this list of conditions and the following disclaimer in the documentation
17
+ # and/or other materials provided with the distribution.
18
+ #
19
+ # 3. Neither the name of the copyright holder nor the names of its contributors
20
+ # may be used to endorse or promote products derived from this software
21
+ # without specific prior written permission.
22
+ #
23
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
24
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
25
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
27
+ # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
28
+ # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
30
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
31
+ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33
+ #
34
+ # pylint: disable=invalid-name
35
+ # pylint: disable=too-many-lines
36
+ #
37
+ """
38
+ git-cl: A pre-staging layer for organising changes in Git.
39
+
40
+ GitHub: https://github.com/BHFock/git-cl
41
+ DOI: https://doi.org/10.5281/zenodo.18722077
42
+
43
+ git-cl brings changelist support to Git, allowing developers to partition
44
+ modified files into named groups before staging or committing. Changelists
45
+ persist across sessions in a JSON file inside the .git directory, and
46
+ integrate transparently with existing Git workflows.
47
+
48
+ Features:
49
+ - Group files into named changelists (add, remove, status)
50
+ - Stage, unstage, commit, or diff by changelist
51
+ - Checkout (revert) changelist files to HEAD
52
+ - Stash and unstash changelists selectively
53
+ - Promote a changelist to a dedicated branch (late-binding branching)
54
+
55
+ Single file, zero dependencies beyond Python 3.9+ and Git.
56
+ Cross-platform: Unix (fcntl) and Windows (msvcrt) file locking.
57
+ """
58
+
59
+ __version__ = "1.1.2"
60
+
61
+ import argparse
62
+ import datetime
63
+ import json
64
+ import os
65
+ import subprocess
66
+ import sys
67
+ if sys.platform == "win32":
68
+ import msvcrt
69
+ else:
70
+ import fcntl
71
+ from contextlib import contextmanager
72
+ from pathlib import Path
73
+ from typing import Optional, Iterator
74
+
75
+ if sys.version_info < (3, 9):
76
+ print("Error: git-cl requires Python 3.9+", file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ # Try to import colorama for colored output of 'git cl st'
80
+ # use dummy objects if colorama is not available to avoid NameError
81
+ try:
82
+ from colorama import Fore, Style
83
+ COLORAMA_AVAILABLE = True
84
+ except ImportError:
85
+ COLORAMA_AVAILABLE = False
86
+
87
+ # pylint: disable=too-few-public-methods
88
+ class _DummyColorama:
89
+ BLUE = ""
90
+ GREEN = ""
91
+ MAGENTA = ""
92
+ RED = ""
93
+ RESET_ALL = ""
94
+ Fore = _DummyColorama()
95
+ Style = _DummyColorama()
96
+
97
+ def _relpath(path, start):
98
+ """Like os.path.relpath, but always returns forward slashes."""
99
+ return os.path.relpath(path, start).replace("\\", "/")
100
+
101
+ # =============================================================================
102
+ # INTERNAL UTILITIES
103
+ # =============================================================================
104
+
105
+ # Note: Some utility functions call CLI command functions like cl_stash and
106
+ # cl_unstash. These are defined later in the module but are called by
107
+ # utility functions.
108
+
109
+
110
+ @contextmanager
111
+ def clutil_file_lock(lock_path: Path):
112
+ """
113
+ Acquires an exclusive lock on a file for safe concurrent access.
114
+
115
+ Creates a lock file and acquires an exclusive lock to prevent concurrent
116
+ access to shared resources. The lock file is automatically cleaned up
117
+ when the context exits, regardless of whether an exception occurs.
118
+
119
+ Args:
120
+ lock_path (Path): Path to the lock file to create and lock.
121
+
122
+ Yields:
123
+ None
124
+
125
+ Raises:
126
+ OSError: If the lock file cannot be created or locked.
127
+
128
+ Platform notes:
129
+ On Unix, ``fcntl.flock`` acquires a whole-file advisory lock that
130
+ blocks indefinitely until the lock becomes available.
131
+ On Windows, ``msvcrt.locking`` locks a single byte (the file is
132
+ empty and used only for mutual exclusion, so one byte suffices).
133
+ Unlike ``fcntl.flock``, ``msvcrt.locking`` with ``LK_LOCK`` retries
134
+ for approximately ten seconds before raising ``OSError``. This is
135
+ acceptable because changelist operations are fast.
136
+
137
+ Example:
138
+ >>> lock_file = Path('.git/cl.lock')
139
+ >>> with clutil_file_lock(lock_file):
140
+ ... # Perform operations that need exclusive access
141
+ ... pass
142
+ # Lock file is automatically cleaned up here
143
+ """
144
+ lock_file = None
145
+ try:
146
+ lock_file = open(lock_path, 'w', encoding='utf-8')
147
+ if sys.platform == "win32":
148
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
149
+ else:
150
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
151
+ yield
152
+ finally:
153
+ if lock_file:
154
+ if sys.platform == "win32":
155
+ try:
156
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
157
+ except OSError:
158
+ pass
159
+ else:
160
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
161
+ lock_file.close()
162
+ lock_path.unlink(missing_ok=True)
163
+
164
+
165
+ def clutil_should_use_color(args) -> bool:
166
+ """
167
+ Determines whether coloured output should be used in CLI display.
168
+
169
+ This function checks multiple conditions to decide if colour formatting
170
+ is appropriate:
171
+ - The --no-color flag is not set.
172
+ - The NO_COLOR environment variable is not set.
173
+ - The output is being sent to a terminal (TTY).
174
+ - The 'colorama' module is available.
175
+
176
+ Args:
177
+ args (argparse.Namespace): Parsed command-line arguments, expected to
178
+ include the 'no_color' attribute.
179
+
180
+ Returns:
181
+ bool: True if coloured output should be enabled, False otherwise.
182
+ """
183
+ no_color_env = bool(os.environ.get('NO_COLOR'))
184
+ is_tty = sys.stdout.isatty()
185
+ return (not (args.no_color or no_color_env or not is_tty) and
186
+ COLORAMA_AVAILABLE)
187
+
188
+
189
+ def clutil_resolve_commit_message(args: argparse.Namespace) -> Optional[str]:
190
+ """
191
+ Resolves the commit message from CLI arguments.
192
+
193
+ Args:
194
+ args (argparse.Namespace): Parsed arguments with 'message' or 'file'.
195
+
196
+ Returns:
197
+ Optional[str]: The commit message, or None if resolution fails.
198
+ """
199
+ if args.message:
200
+ return args.message
201
+
202
+ if args.file:
203
+ try:
204
+ return clutil_read_commit_message_file(args.file)
205
+ except (OSError, ValueError) as error:
206
+ print(f"Error reading commit message file '{args.file}': {error}")
207
+ return None
208
+
209
+ print("Error: No commit message provided.")
210
+ return None
211
+
212
+
213
+ def clutil_get_file() -> Path:
214
+ """
215
+ Returns the path to the changelist file inside the Git directory.
216
+
217
+ Returns:
218
+ Path: Path to the '.git/cl.json' file.
219
+
220
+ Raises:
221
+ SystemExit: If not inside a Git repository.
222
+ """
223
+ try:
224
+ git_dir = subprocess.check_output(
225
+ ["git", "rev-parse", "--git-dir"],
226
+ stderr=subprocess.DEVNULL,
227
+ text=True
228
+ ).strip()
229
+ return Path(git_dir) / "cl.json"
230
+ except subprocess.CalledProcessError:
231
+ print("Error: Not inside a Git repository.")
232
+ print("Please run this command from within a Git repository.")
233
+ sys.exit(1)
234
+
235
+
236
+ def clutil_load() -> dict[str, list[str]]:
237
+ """
238
+ Loads changelist data from the 'cl.json' file.
239
+
240
+ Returns:
241
+ dict: A mapping of changelist names to lists of file paths.
242
+ """
243
+ changelist_file = clutil_get_file()
244
+ lock_file = changelist_file.with_suffix('.lock')
245
+ if changelist_file.exists():
246
+ with clutil_file_lock(lock_file):
247
+ try:
248
+ with open(changelist_file, "r", encoding="utf-8") as file_handle:
249
+ return json.load(file_handle)
250
+ except (json.JSONDecodeError, OSError) as error:
251
+ print(f"Error reading changelists: {error}")
252
+ return {}
253
+ return {}
254
+
255
+
256
+ def clutil_save(data: dict[str, list[str]]) -> None:
257
+ """
258
+ Saves the changelist data to 'cl.json', omitting empty changelists.
259
+
260
+ Args:
261
+ data (dict): Mapping of changelist names to lists of files.
262
+ """
263
+ changelist_file = clutil_get_file()
264
+ lock_file = changelist_file.with_suffix('.lock')
265
+ cleaned = {k: v for k, v in data.items() if v}
266
+ with clutil_file_lock(lock_file):
267
+ try:
268
+ with open(changelist_file, "w", encoding="utf-8") as file_handle:
269
+ json.dump(cleaned, file_handle, indent=2)
270
+ except OSError as error:
271
+ print(f"Error saving changelists: {error}")
272
+
273
+
274
+ def clutil_validate_name(name: str) -> bool:
275
+ """
276
+ Validates that a changelist name is safe to use.
277
+
278
+ Args:
279
+ name (str): The changelist name to validate.
280
+
281
+ Returns:
282
+ bool: True if the name is valid, False otherwise.
283
+ """
284
+ # Check for empty names
285
+ if not name:
286
+ return False
287
+
288
+ # Check for reasonable length (max 100 characters)
289
+ if len(name) > 100:
290
+ return False
291
+
292
+ # Only allow safe characters: letters, numbers, hyphens, underscores, dots
293
+ allowed_chars = set(
294
+ 'abcdefghijklmnopqrstuvwxyz'
295
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
296
+ '0123456789-_.'
297
+ )
298
+
299
+ if not all(char in allowed_chars for char in name):
300
+ return False
301
+
302
+ # Don't allow names that are only dots (filesystem issues)
303
+ if all(char == '.' for char in name):
304
+ return False
305
+
306
+ # Check for Git reserved words (only the critical ones)
307
+ reserved_names = {'HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD',
308
+ 'CHERRY_PICK_HEAD', 'index'}
309
+ if name in reserved_names:
310
+ return False
311
+
312
+ return True
313
+
314
+
315
+ def clutil_get_git_root() -> Path:
316
+ """
317
+ Returns the absolute path to the root of the current Git repository.
318
+
319
+ Returns:
320
+ Path: The top-level directory of the Git repository.
321
+
322
+ Raises:
323
+ SystemExit: If the current directory is not inside a Git repository.
324
+ """
325
+ try:
326
+ return Path(
327
+ subprocess.check_output(
328
+ ["git", "rev-parse", "--show-toplevel"],
329
+ text=True
330
+ ).strip()
331
+ ).resolve()
332
+ except subprocess.CalledProcessError as error:
333
+ print(f"Error: Not inside a Git repository. {error}")
334
+ sys.exit(1)
335
+
336
+
337
+ def clutil_get_git_status(include_untracked: bool = False) -> list[str]:
338
+ """
339
+ Returns the output of 'git status --porcelain' as a list of lines.
340
+
341
+ Args:
342
+ include_untracked (bool): If True, includes untracked files using
343
+ '--untracked-files=all'.
344
+
345
+ Returns:
346
+ list[str]: Each line represents a file's status.
347
+
348
+ Raises:
349
+ SystemExit: If the git command fails.
350
+ """
351
+ cmd = ["git", "status", "--porcelain"]
352
+ if include_untracked:
353
+ cmd.append("--untracked-files=all")
354
+
355
+ try:
356
+ return subprocess.check_output(cmd, text=True).splitlines()
357
+ except subprocess.CalledProcessError as error:
358
+ print(f"Error getting git status: {error}")
359
+ sys.exit(1)
360
+
361
+
362
+ def clutil_sanitize_path(file_path: str, git_root: Path) -> Optional[str]:
363
+ """
364
+ Sanitizes and validates a file path for safe use with Git commands.
365
+
366
+ Args:
367
+ file_path (str): The file path to sanitize
368
+ git_root (Path): The root directory of the Git repository
369
+
370
+ Returns:
371
+ str | None: The sanitized path relative to git_root, or None if invalid
372
+ """
373
+ try:
374
+ # Convert to Path object and resolve any relative components
375
+ path = Path(file_path)
376
+
377
+ # If it's relative, make it relative to current working directory
378
+ if not path.is_absolute():
379
+ path = Path.cwd().resolve() / path
380
+
381
+ # Resolve any .. or . components and get absolute path
382
+ path = path.resolve()
383
+
384
+ # Ensure the path is within the Git repository
385
+ try:
386
+ relative_path = path.relative_to(git_root)
387
+ except ValueError:
388
+ # Path is outside the Git repository
389
+ return None
390
+
391
+ # Convert back to string using forward slashes (Git standard)
392
+ sanitized = relative_path.as_posix()
393
+
394
+ # Basic security check: reject paths with dangerous characters
395
+ dangerous_chars = [';', '|', '&', '`', '$', '\n', '\r', '\0']
396
+ if any(char in sanitized for char in dangerous_chars):
397
+ return None
398
+
399
+ return sanitized
400
+
401
+ except (OSError, ValueError, RuntimeError):
402
+ # Any path resolution errors
403
+ return None
404
+
405
+
406
+ def clutil_get_file_status_map(show_all: bool = False) -> dict[str, str]:
407
+ """
408
+ Get a mapping of files to their Git 2-letter status codes.
409
+
410
+ By default, only a predefined set of common status codes are included.
411
+ Files with uncommon status codes (e.g. merge conflicts, type changes)
412
+ are skipped and counted for a warning message — unless `--all` is used.
413
+
414
+ Ignored files (status code '! ') are silently excluded from both the
415
+ output and the warning, since `git status --porcelain` does not include
416
+ them unless explicitly requested with `--ignored`.
417
+
418
+ Args:
419
+ show_all (bool): If True, include all files regardless of status code.
420
+
421
+ Returns:
422
+ dict[str, str]: Mapping of relative file paths
423
+ to their Git status codes.
424
+ """
425
+ git_root = clutil_get_git_root()
426
+ output = clutil_get_git_status(include_untracked=True)
427
+
428
+ # Allowlist of known meaningful status codes
429
+ INTERESTING_CODES = {
430
+ '??', ' M', 'M ', 'MM', 'A ', 'AM', ' D', 'D ', 'R ', 'RM'
431
+ }
432
+
433
+ status_map = {}
434
+ skipped = {}
435
+
436
+ for line in output:
437
+ if len(line) < 4:
438
+ continue # malformed line, ignore
439
+ code = line[:2]
440
+ raw_path = line[3:].strip()
441
+
442
+ # Handle renamed files
443
+ if code.startswith('R') and '->' in raw_path:
444
+ raw_path = raw_path.split('->')[-1].strip()
445
+
446
+ abs_path = (git_root / raw_path).resolve()
447
+ try:
448
+ rel_path = abs_path.relative_to(git_root).as_posix()
449
+ except ValueError:
450
+ rel_path = raw_path
451
+
452
+ if show_all or code in INTERESTING_CODES:
453
+ status_map[rel_path] = code
454
+ else:
455
+ skipped.setdefault(code, []).append(rel_path)
456
+
457
+ if skipped and not show_all:
458
+ skipped_count = sum(len(v) for v in skipped.values())
459
+ print(f"Note: {skipped_count} file(s) with uncommon Git status codes "
460
+ "were not shown. Use 'git cl st --all' to include them.")
461
+
462
+ return status_map
463
+
464
+
465
+ def clutil_format_file_status(
466
+ file: str, status_map: dict[str, str], git_root: Path,
467
+ use_color: bool = True) -> str:
468
+ """
469
+ Format a file's Git status line with optional color-coded output.
470
+ Paths are shown relative to the current working directory.
471
+ """
472
+ abs_file = (git_root / file).resolve()
473
+ rel_to_cwd = _relpath(abs_file, Path.cwd().resolve())
474
+
475
+ rel_to_git_root = abs_file.relative_to(git_root).as_posix()
476
+ status = status_map.get(rel_to_git_root, " ")
477
+
478
+ # Add detection of files which are deleted (committed) but still
479
+ # included in a changelists
480
+ if not abs_file.exists() and status == " ":
481
+ status = "XX" # Custom code for missing file
482
+ print(f"Warning: The file '{rel_to_cwd}' does not exist "
483
+ f"on the current branch.\n"
484
+ f" Remove it from changelist with "
485
+ f"'git cl rm {rel_to_cwd}'.")
486
+
487
+ tag = f"[{status}]"
488
+
489
+ if not use_color or not COLORAMA_AVAILABLE:
490
+ return f" {tag} {rel_to_cwd}"
491
+
492
+ staged, unstaged = status[0], status[1]
493
+
494
+ if status == "??":
495
+ color = Fore.BLUE
496
+ elif staged == 'A':
497
+ color = Fore.GREEN
498
+ elif staged != " " and unstaged == " ":
499
+ color = Fore.GREEN
500
+ elif staged == " " and unstaged != " ":
501
+ color = Fore.RED
502
+ elif staged != " " and unstaged != " ":
503
+ color = Fore.MAGENTA
504
+ else:
505
+ color = Style.RESET_ALL
506
+
507
+ return f"{color} {tag} {rel_to_cwd}{Style.RESET_ALL}"
508
+
509
+
510
+ def clutil_read_commit_message_file(file_path: str) -> str:
511
+ """
512
+ Reads a commit message from a file, with validation and processing.
513
+
514
+ Args:
515
+ file_path (str): Path to the file containing the commit message
516
+
517
+ Returns:
518
+ str: The commit message content
519
+
520
+ Raises:
521
+ OSError: If the file cannot be read
522
+ ValueError: If the file is empty or contains only whitespace
523
+ """
524
+ try:
525
+ # Resolve path relative to current working directory
526
+ path = Path(file_path).resolve()
527
+
528
+ # Basic security check - ensure file exists and is readable
529
+ if not path.exists():
530
+ raise OSError(f"File does not exist: {file_path}")
531
+
532
+ if not path.is_file():
533
+ raise OSError(f"Path is not a file: {file_path}")
534
+
535
+ # Reasonable size limit for commit messages (generous 64KB)
536
+ if path.stat().st_size > 64 * 1024:
537
+ raise ValueError("Commit message file too large (>64KB)")
538
+
539
+ # Read the file
540
+ with open(path, 'r', encoding='utf-8') as f:
541
+ content = f.read(64 * 1024) # Enforce the same limit
542
+
543
+ except (OSError, UnicodeDecodeError) as error:
544
+ raise OSError(f"Cannot read file: {error}") from error
545
+
546
+ # Validate content
547
+ if not content or not content.strip():
548
+ raise ValueError(
549
+ "Commit message file is empty or contains only whitespace")
550
+
551
+ # Process the content similar to how Git does it:
552
+ # - Strip trailing whitespace from each line
553
+ # - Remove trailing empty lines
554
+ lines = [line.rstrip() for line in content.splitlines()]
555
+
556
+ # Remove trailing empty lines
557
+ while lines and not lines[-1]:
558
+ lines.pop()
559
+
560
+ if not lines:
561
+ raise ValueError(
562
+ "Commit message file contains no content after processing")
563
+
564
+ # Join lines back together
565
+ processed_content = '\n'.join(lines)
566
+
567
+ return processed_content
568
+
569
+
570
+ def clutil_is_file_untracked(
571
+ file_path_rel_to_git_root: str, git_root: Path) -> bool:
572
+ """
573
+ Check if a file (specified relative to git root) is untracked.
574
+
575
+ Args:
576
+ file_path_rel_to_git_root: File path relative to git repository root
577
+ git_root: Absolute path to git repository root
578
+
579
+ Returns:
580
+ True if the file is untracked, False if it's tracked
581
+ """
582
+ # Get git status output
583
+ output = clutil_get_git_status(include_untracked=True)
584
+
585
+ # Get absolute path of the file we're checking
586
+ abs_file_path = (git_root / file_path_rel_to_git_root).resolve()
587
+
588
+ # Check untracked files and directories
589
+ for line in output:
590
+ if line.startswith("??"):
591
+ raw_path = line[3:].strip()
592
+ abs_untracked = (git_root / raw_path).resolve()
593
+
594
+ # Direct match for files
595
+ if abs_file_path == abs_untracked:
596
+ return True
597
+
598
+ # Check if file is under an untracked directory
599
+ if (
600
+ abs_untracked.is_dir()
601
+ and abs_file_path.is_relative_to(abs_untracked)
602
+ ):
603
+ return True
604
+
605
+ return False
606
+
607
+
608
+ def clutil_get_stash_file() -> Path:
609
+ """
610
+ Returns the path to the stash metadata file inside the Git directory.
611
+
612
+ Returns:
613
+ Path: Path to the '.git/cl-stashes.json' file.
614
+ """
615
+ git_dir = Path(subprocess.check_output(
616
+ ["git", "rev-parse", "--git-dir"],
617
+ stderr=subprocess.DEVNULL,
618
+ text=True
619
+ ).strip())
620
+ return git_dir / "cl-stashes.json"
621
+
622
+
623
+ def clutil_load_stashes() -> dict[str, dict]:
624
+ """
625
+ Loads stash metadata from the 'cl-stashes.json' file.
626
+
627
+ Returns:
628
+ dict: A mapping of stashed changelist names to their metadata.
629
+ """
630
+ stash_file = clutil_get_stash_file()
631
+ lock_file = stash_file.with_suffix('.lock')
632
+ if stash_file.exists():
633
+ with clutil_file_lock(lock_file):
634
+ try:
635
+ with open(stash_file, "r", encoding="utf-8") as file_handle:
636
+ return json.load(file_handle)
637
+ except (json.JSONDecodeError, OSError) as error:
638
+ print(f"Error reading stash metadata: {error}")
639
+ return {}
640
+ return {}
641
+
642
+
643
+ def clutil_save_stashes(data: dict[str, dict]) -> None:
644
+ """
645
+ Saves the stash metadata to 'cl-stashes.json'.
646
+
647
+ Args:
648
+ data (dict): Mapping of stashed changelist names to metadata.
649
+ """
650
+ stash_file = clutil_get_stash_file()
651
+ lock_file = stash_file.with_suffix('.lock')
652
+ with clutil_file_lock(lock_file):
653
+ try:
654
+ with open(stash_file, "w", encoding="utf-8") as file_handle:
655
+ json.dump(data, file_handle, indent=2)
656
+ except OSError as error:
657
+ print(f"Error saving stash metadata: {error}")
658
+
659
+
660
+ def clutil_check_files_unstaged(files: list[str],
661
+ git_root: Path) -> tuple[list[str], list[str]]:
662
+ """
663
+ Check which files in the list have staged vs unstaged changes.
664
+
665
+ Args:
666
+ files: List of file paths relative to git root
667
+ git_root: Path to git repository root
668
+
669
+ Returns:
670
+ tuple: (unstaged_files, staged_files)
671
+ files that can/cannot be stashed
672
+ """
673
+ status_map = clutil_get_file_status_map(show_all=True)
674
+
675
+ unstaged_files = []
676
+ staged_files = []
677
+
678
+ for file_path in files:
679
+ abs_path = (git_root / file_path).resolve()
680
+ if not abs_path.exists():
681
+ continue
682
+
683
+ rel_to_git_root = abs_path.relative_to(git_root).as_posix()
684
+ status = status_map.get(rel_to_git_root, " ")
685
+
686
+ # Check if file has staged changes (first character is not space)
687
+ if status[0] != " ":
688
+ staged_files.append(file_path)
689
+ else:
690
+ unstaged_files.append(file_path)
691
+
692
+ return unstaged_files, staged_files
693
+
694
+
695
+ # Git status analysis for unstash conflicts
696
+ # Maps (staged_char, unstaged_char) -> (is_conflict, description)
697
+ UNSTASH_STATUS_ANALYSIS = {
698
+ ('?', '?'): (True, "untracked file blocking stash restoration"),
699
+ (' ', 'M'): (True, "modified in working directory"),
700
+ (' ', 'D'): (True, "deleted in working directory"),
701
+ ('A', ' '): (False, "staged for addition (safe)"),
702
+ ('M', ' '): (False, "staged modifications (safe)"),
703
+ ('D', ' '): (False, "staged for deletion (safe)"),
704
+ ('R', ' '): (False, "staged rename (safe)"),
705
+ (' ', ' '): (False, "clean (ready for unstash)"),
706
+ }
707
+
708
+
709
+ def clutil_analyze_file_status_for_unstash(status: str) -> tuple[bool, str]:
710
+ """
711
+ Analyze a Git status code to determine unstash conflicts.
712
+
713
+ Args:
714
+ status: 2-character Git status code
715
+
716
+ Returns:
717
+ Tuple of (is_conflict, description)
718
+ """
719
+ if len(status) != 2:
720
+ return False, "unknown status"
721
+
722
+ staged, unstaged = status[0], status[1]
723
+
724
+ # Check direct lookup first
725
+ if (staged, unstaged) in UNSTASH_STATUS_ANALYSIS:
726
+ return UNSTASH_STATUS_ANALYSIS[(staged, unstaged)]
727
+
728
+ # Handle remaining cases with simple logic
729
+ if unstaged in ['M', 'D']:
730
+ if unstaged == 'M':
731
+ conflict_desc = "modified in working directory"
732
+ else:
733
+ conflict_desc = "deleted in working directory"
734
+ return True, conflict_desc
735
+
736
+ if staged in ['A', 'M', 'D', 'R']:
737
+ stage_desc = {
738
+ 'A': "staged for addition (safe)",
739
+ 'M': "staged modifications (safe)",
740
+ 'D': "staged for deletion (safe)",
741
+ 'R': "staged rename (safe)"
742
+ }
743
+ return False, stage_desc.get(staged, "staged changes (safe)")
744
+
745
+ return False, "clean (ready for unstash)"
746
+
747
+
748
+ def clutil_check_unstash_conflicts_optimized(
749
+ files: list[str], git_root: Path) -> tuple[list[str], dict[str, str]]:
750
+ """
751
+ Check for conflicts optimized for the stash-branch-unstash workflow.
752
+
753
+ In the intended workflow:
754
+ 1. Stash changelists → clean working directory
755
+ 2. Create/switch to feature branch
756
+ 3. Unstash specific changelist → should be conflict-free
757
+
758
+ This function focuses on detecting only the conflicts that actually matter
759
+ in this workflow, rather than being overly conservative.
760
+
761
+ Args:
762
+ files: List of file paths relative to git root that were stashed
763
+ git_root: Path to git repository root
764
+
765
+ Returns:
766
+ tuple: (real_conflicts, file_status_info) where:
767
+ - real_conflicts: Files that will actually prevent unstashing
768
+ - file_status_info: Dict of file -> status description for user info
769
+ """
770
+ status_map = clutil_get_file_status_map(show_all=True)
771
+ real_conflicts = []
772
+ file_status_info = {}
773
+
774
+ for file_path in files:
775
+ abs_path = (git_root / file_path).resolve()
776
+
777
+ if not abs_path.exists():
778
+ # File doesn't exist - this is actually GOOD for unstashing
779
+ # The stash will restore it without conflict
780
+ file_status_info[file_path] = "missing (will be restored)"
781
+ continue
782
+
783
+ rel_to_git_root = abs_path.relative_to(git_root).as_posix()
784
+ status = status_map.get(rel_to_git_root, " ")
785
+
786
+ # Use the lookup table for clean analysis
787
+ is_conflict, description = clutil_analyze_file_status_for_unstash(status)
788
+
789
+ if is_conflict:
790
+ real_conflicts.append(file_path)
791
+
792
+ file_status_info[file_path] = description
793
+
794
+ return real_conflicts, file_status_info
795
+
796
+
797
+ def clutil_suggest_workflow_actions(
798
+ conflicting_files: list[str],
799
+ file_status_info: dict[str, str]) -> None:
800
+ """
801
+ Provide workflow-specific suggestions for resolving conflicts.
802
+
803
+ Args:
804
+ conflicting_files: Files that have real conflicts
805
+ file_status_info: Status information for all files
806
+ """
807
+ if not conflicting_files:
808
+ return
809
+
810
+ print("Workflow suggestions to resolve conflicts:")
811
+
812
+ untracked_conflicts = []
813
+ modified_conflicts = []
814
+ deleted_conflicts = []
815
+
816
+ for file_path in conflicting_files:
817
+ status_desc = file_status_info[file_path]
818
+ if "untracked" in status_desc:
819
+ untracked_conflicts.append(file_path)
820
+ elif "modified in working" in status_desc:
821
+ modified_conflicts.append(file_path)
822
+ elif "deleted in working" in status_desc:
823
+ deleted_conflicts.append(file_path)
824
+
825
+ if untracked_conflicts:
826
+ print("\n Untracked files blocking stash restoration:")
827
+ for file_path in untracked_conflicts:
828
+ print(f" {file_path}")
829
+ print(" → Solution: Move, rename, or delete these untracked files")
830
+
831
+ if modified_conflicts:
832
+ print("\n Working directory modifications:")
833
+ for file_path in modified_conflicts:
834
+ print(f" {file_path}")
835
+ print(" → Solution: Stage/commit these changes or revert them")
836
+ print(f" git add {' '.join(modified_conflicts)} "
837
+ "&& git commit -m 'Save current work'")
838
+ print(f" # OR: git checkout -- {' '.join(modified_conflicts)}")
839
+
840
+ if deleted_conflicts:
841
+ print("\n Files deleted in working directory:")
842
+ for file_path in deleted_conflicts:
843
+ print(f" {file_path}")
844
+ print(" → Solution: If intentional, stage the deletions;"
845
+ "if not, restore the files")
846
+ print(f" git rm {' '.join(deleted_conflicts)} "
847
+ "&& git commit -m 'Remove files'")
848
+ print(f" # OR: git checkout HEAD -- {' '.join(deleted_conflicts)}")
849
+
850
+
851
+ def clutil_get_current_branch() -> Optional[str]:
852
+ """
853
+ Get the name of the current Git branch.
854
+
855
+ Returns:
856
+ Branch name or None if not on a branch (detached HEAD)
857
+ """
858
+ try:
859
+ result = subprocess.run(["git", "branch", "--show-current"],
860
+ capture_output=True, text=True, check=True)
861
+ branch = result.stdout.strip()
862
+ return branch if branch else None
863
+ except subprocess.CalledProcessError:
864
+ return None
865
+
866
+
867
+ @contextmanager
868
+ def clutil_safe_stash_context(
869
+ git_root: Path,
870
+ files_for_git: list[str],
871
+ untracked_files_for_git: list[str]) -> Iterator[tuple[list[str],
872
+ list[str]]]:
873
+ """
874
+ Context manager to handle directory changes during stash operations.
875
+ Recalculates file paths relative to git root.
876
+
877
+ Args:
878
+ git_root: Path to git repository root
879
+ files_for_git: Original file paths relative to current directory
880
+ untracked_files_for_git: Original untracked file paths
881
+ relative to current directory
882
+
883
+ Yields:
884
+ tuple[list[str], list[str]]: (files_for_git, untracked_files_for_git)
885
+ relative to git root
886
+ """
887
+ original_cwd = Path.cwd().resolve()
888
+
889
+ def convert_to_git_root_relative(file_paths: list[str]) -> list[str]:
890
+ """Convert file paths to be relative to git root."""
891
+ result = []
892
+ for file_path in file_paths:
893
+ abs_path = (original_cwd / file_path).resolve()
894
+ try:
895
+ git_root_relative = abs_path.relative_to(git_root)
896
+ result.append(git_root_relative.as_posix())
897
+ except ValueError:
898
+ # Path is outside git root - should not happen with validation
899
+ result.append(file_path)
900
+ return result
901
+
902
+ # Convert both file lists
903
+ safe_files = convert_to_git_root_relative(files_for_git)
904
+ safe_untracked_files = convert_to_git_root_relative(untracked_files_for_git)
905
+
906
+ # Change to git root
907
+ os.chdir(git_root)
908
+
909
+ try:
910
+ yield safe_files, safe_untracked_files
911
+ finally:
912
+ # Try to restore original directory
913
+ if original_cwd.exists():
914
+ os.chdir(original_cwd)
915
+ else:
916
+ # Directory was deleted - warn user and stay in git root
917
+ try:
918
+ rel_path = _relpath(original_cwd, git_root)
919
+ print(f"Note: Directory '{rel_path}' was removed by git stash.")
920
+ print("Your shell is still pointing to the deleted directory.")
921
+ print("Please run: cd ..")
922
+ except (ValueError, OSError):
923
+ print("Note: Your working directory was removed by git stash.")
924
+ print("Your shell is still pointing to the deleted directory.")
925
+ print("Please run: cd ..")
926
+
927
+
928
+ def clutil_get_stash_source_branch(stash_data: dict) -> Optional[str]:
929
+ """
930
+ Extract the branch name from stash metadata if stored.
931
+
932
+ Args:
933
+ stash_data: Stash metadata dictionary
934
+
935
+ Returns:
936
+ Branch name or None if not available
937
+ """
938
+ return stash_data.get('source_branch')
939
+
940
+
941
+ def clutil_find_stash_by_message(message: str) -> Optional[str]:
942
+ """
943
+ Find a stash reference by its commit message.
944
+
945
+ Args:
946
+ message: The stash message to search for
947
+
948
+ Returns:
949
+ The stash reference (e.g. "stash@{0}") or None if not found
950
+ """
951
+ try:
952
+ stash_list = subprocess.check_output(["git", "stash", "list"],
953
+ text=True)
954
+ for line in stash_list.strip().split('\n'):
955
+ if line and message in line:
956
+ return line.split(':')[0]
957
+ except subprocess.CalledProcessError:
958
+ pass
959
+ return None
960
+
961
+
962
+ def clutil_create_unique_stash_message(changelist_name: str) -> str:
963
+ """
964
+ Create a unique stash message that includes changelist name and timestamp.
965
+ This helps identify our stashes even when mixed with user-created stashes.
966
+
967
+ Args:
968
+ changelist_name: Name of the changelist being stashed
969
+
970
+ Returns:
971
+ Unique stash message string
972
+ """
973
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
974
+ return f"git-cl-stash:{changelist_name}:{timestamp}"
975
+
976
+
977
+ def clutil_parse_stash_message(
978
+ stash_message: str) -> Optional[tuple[str, str]]:
979
+ """
980
+ Parse our stash message format to extract changelist name and timestamp.
981
+
982
+ Args:
983
+ stash_message: The stash message to parse
984
+
985
+ Returns:
986
+ Tuple of (changelist_name, timestamp) or None if not our format
987
+ """
988
+ if stash_message.startswith("git-cl-stash:"):
989
+ try:
990
+ parts = stash_message.split(":")
991
+ if len(parts) >= 3:
992
+ changelist_name = parts[1]
993
+ timestamp = parts[2]
994
+ return changelist_name, timestamp
995
+ except (IndexError, ValueError):
996
+ pass
997
+ return None
998
+
999
+
1000
+ def clutil_find_stash_by_message_substring(
1001
+ message_substring: str) -> Optional[str]:
1002
+ """
1003
+ Find a stash reference by searching for a substring in stash messages.
1004
+
1005
+ Args:
1006
+ message_substring: Part of the stash message to search for
1007
+
1008
+ Returns:
1009
+ The stash reference (e.g. "stash@{2}") or None if not found
1010
+ """
1011
+ try:
1012
+ stash_list = subprocess.check_output(["git", "stash", "list"],
1013
+ text=True)
1014
+ for line in stash_list.strip().split('\n'):
1015
+ if line and message_substring in line:
1016
+ # Extract stash reference from format:
1017
+ # "stash@{0}: WIP on main: abc1234 message"
1018
+ stash_ref = line.split(':')[0].strip()
1019
+ return stash_ref
1020
+ except subprocess.CalledProcessError:
1021
+ pass
1022
+ return None
1023
+
1024
+
1025
+ def clutil_rollback_stash(stash_ref: str, changelist_name: str) -> bool:
1026
+ """
1027
+ Attempt to rollback a stash creation by dropping the stash.
1028
+
1029
+ Args:
1030
+ stash_ref: The stash reference to drop (e.g. "stash@{0}")
1031
+ changelist_name: Name of changelist for error reporting
1032
+
1033
+ Returns:
1034
+ True if rollback succeeded, False otherwise
1035
+ """
1036
+ try:
1037
+ # Attempt to drop the stash
1038
+ subprocess.run(["git", "stash", "drop", stash_ref],
1039
+ check=True, capture_output=True, text=True)
1040
+ print(f"Rolled back stash {stash_ref} due to metadata save failure.")
1041
+ return True
1042
+ except subprocess.CalledProcessError as error:
1043
+ print(f"Warning: Could not rollback stash {stash_ref}: {error}")
1044
+ print(f"You may need to manually clean up stash for changelist "
1045
+ f"'{changelist_name}'")
1046
+ return False
1047
+
1048
+
1049
+ def clutil_stash_all_changelists(changelists: dict[str, list[str]],
1050
+ quiet: bool = False) -> None:
1051
+ """
1052
+ Helper function to stash all active changelists.
1053
+
1054
+ Args:
1055
+ changelists: Dictionary of active changelists
1056
+ quiet: If True, suppress verbose output for workflow contexts
1057
+ """
1058
+ if not changelists:
1059
+ if not quiet:
1060
+ print("No active changelists found.")
1061
+ return
1062
+
1063
+ # Get current branch for tracking
1064
+ current_branch = clutil_get_current_branch()
1065
+
1066
+ if not quiet:
1067
+ print(f"Stashing all active changelists from branch "
1068
+ f"'{current_branch or 'detached HEAD'}':")
1069
+
1070
+ success_count = 0
1071
+
1072
+ # Sort changelists by name for predictable order
1073
+ sorted_changelists = sorted(changelists.items())
1074
+
1075
+ for cl_name, files in sorted_changelists:
1076
+ if not files: # Skip empty changelists
1077
+ continue
1078
+
1079
+ if not quiet:
1080
+ print(f"\n--- Stashing '{cl_name}' ---")
1081
+
1082
+ # Create a temporary args object for individual stash
1083
+ temp_args = argparse.Namespace()
1084
+ temp_args.name = cl_name
1085
+ temp_args.all = False
1086
+
1087
+ try:
1088
+ # Call cl_stash with quiet mode
1089
+ cl_stash(temp_args, quiet=quiet)
1090
+ success_count += 1
1091
+ except (OSError, subprocess.CalledProcessError,
1092
+ json.JSONDecodeError) as error:
1093
+ print(f"Failed to stash '{cl_name}': {error}")
1094
+ continue
1095
+
1096
+ if not quiet:
1097
+ print(
1098
+ f"\nStashed {success_count}/"
1099
+ f"{len([cl for cl, files in sorted_changelists if files])} "
1100
+ "changelists successfully."
1101
+ )
1102
+ if success_count > 0:
1103
+ print("Working directory should now be clean for branch operations.")
1104
+ print("Use 'git cl unstash <changelist>' to restore individual "
1105
+ "changelists to feature branches.")
1106
+
1107
+
1108
+ def clutil_validate_stash_preconditions(
1109
+ name: str,
1110
+ changelists: dict[str, list[str]],
1111
+ stashes: dict[str, dict]
1112
+ ) -> Optional[str]:
1113
+ """
1114
+ Validate preconditions for stashing a changelist.
1115
+
1116
+ Args:
1117
+ name: Changelist name
1118
+ changelists: Active changelists
1119
+ stashes: Already stashed changelists
1120
+
1121
+ Returns:
1122
+ Error message if validation fails, None if all checks pass
1123
+ """
1124
+ # Check if changelist exists
1125
+ if name not in changelists:
1126
+ return f"Changelist '{name}' not found."
1127
+
1128
+ # Check if changelist is already stashed
1129
+ if name in stashes:
1130
+ return (f"Changelist '{name}' is already stashed.\n"
1131
+ f"Use 'git cl unstash {name}' to restore it first.")
1132
+
1133
+ # Check if changelist is empty
1134
+ if not changelists[name]:
1135
+ return f"Changelist '{name}' is empty - nothing to stash."
1136
+
1137
+ return None
1138
+
1139
+
1140
+ def clutil_categorize_files_for_stash(
1141
+ files: list[tuple[str, Path]],
1142
+ missing_files: list[str],
1143
+ status_map: dict[str, str],
1144
+ git_root: Path
1145
+ ) -> tuple[dict[str, list[str]], list[str], list[str]]:
1146
+ """
1147
+ Categorize files based on their Git status for stashing.
1148
+
1149
+ Args:
1150
+ files: List of (stored_path, abs_path) tuples for existing files
1151
+ missing_files: List of paths for non-existent files
1152
+ status_map: Git status mapping
1153
+ git_root: Git repository root
1154
+
1155
+ Returns:
1156
+ Tuple of (file_categories, stashable_files, unstashable_files)
1157
+ """
1158
+ file_categories = {
1159
+ 'unstaged_changes': [], # Modified, deleted in working directory
1160
+ 'staged_additions': [], # Newly added to index (A_)
1161
+ 'untracked': [], # Untracked files (??)
1162
+ 'deleted_files': [], # Files that don't exist (potential deletions)
1163
+ 'clean_files': [], # Clean files (not stashable)
1164
+ 'staged_modifications': [] # Files with only staged changes (not stashable)
1165
+ }
1166
+
1167
+ stashable_files = []
1168
+ unstashable_files = []
1169
+
1170
+ # Process existing files
1171
+ for stored_path, abs_path in files:
1172
+ rel_to_git_root = abs_path.relative_to(git_root).as_posix()
1173
+ status = status_map.get(rel_to_git_root, " ")
1174
+ staged, unstaged = status[0], status[1]
1175
+
1176
+ if status == "??":
1177
+ # Untracked files
1178
+ # - stashable since they're explicitly in changelist
1179
+ file_categories['untracked'].append(stored_path)
1180
+ stashable_files.append(stored_path)
1181
+ elif unstaged in ['M', 'D']:
1182
+ # Unstaged modifications/deletions - stashable
1183
+ file_categories['unstaged_changes'].append(stored_path)
1184
+ stashable_files.append(stored_path)
1185
+ elif staged == 'A' and unstaged == ' ':
1186
+ # Newly added to index - stashable
1187
+ file_categories['staged_additions'].append(stored_path)
1188
+ stashable_files.append(stored_path)
1189
+ elif staged in ['M', 'D', 'R'] and unstaged == ' ':
1190
+ # Only staged changes - not stashable with git stash push
1191
+ file_categories['staged_modifications'].append(stored_path)
1192
+ unstashable_files.append(stored_path)
1193
+ elif status == " ":
1194
+ # Clean files - not stashable
1195
+ file_categories['clean_files'].append(stored_path)
1196
+ unstashable_files.append(stored_path)
1197
+ else:
1198
+ # Mixed staged+unstaged or other complex states
1199
+ if unstaged != ' ':
1200
+ # Has unstaged component - stashable
1201
+ file_categories['unstaged_changes'].append(stored_path)
1202
+ stashable_files.append(stored_path)
1203
+ else:
1204
+ # Only staged component - not stashable
1205
+ file_categories['staged_modifications'].append(stored_path)
1206
+ unstashable_files.append(stored_path)
1207
+
1208
+ # Process missing files (potential deletions)
1209
+ for stored_path in missing_files:
1210
+ # Check if this is a deletion by looking at git status
1211
+ rel_to_git_root = stored_path # already relative to git root
1212
+ status = status_map.get(rel_to_git_root, " ")
1213
+
1214
+ if status[1] == 'D': # Deleted in working directory
1215
+ file_categories['deleted_files'].append(stored_path)
1216
+ stashable_files.append(stored_path)
1217
+ else:
1218
+ # File just doesn't exist and git doesn't know about it
1219
+ print(f"Warning: File '{stored_path}' "
1220
+ f"does not exist and is not tracked.")
1221
+ unstashable_files.append(stored_path)
1222
+
1223
+ return file_categories, stashable_files, unstashable_files
1224
+
1225
+
1226
+ def clutil_report_stash_categorization(
1227
+ file_categories: dict[str, list[str]],
1228
+ unstashable_files: list[str],
1229
+ name: str,
1230
+ quiet: bool = False
1231
+ ) -> bool:
1232
+ """
1233
+ Report file categorization results and check if stash can proceed.
1234
+
1235
+ Args:
1236
+ file_categories: Categorized files
1237
+ unstashable_files: List of unstashable files
1238
+ name: Changelist name
1239
+ quiet: If True, suppress verbose output
1240
+
1241
+ Returns:
1242
+ True if stash can proceed, False otherwise
1243
+ """
1244
+ # Report what we found (unless in quiet mode)
1245
+ if not quiet:
1246
+ if file_categories['unstaged_changes']:
1247
+ print(f"Files with unstaged changes: "
1248
+ f"{len(file_categories['unstaged_changes'])}")
1249
+ if file_categories['staged_additions']:
1250
+ print(f"Newly added files: "
1251
+ f"{len(file_categories['staged_additions'])}")
1252
+ if file_categories['untracked']:
1253
+ print(f"Untracked files: {len(file_categories['untracked'])}")
1254
+ if file_categories['deleted_files']:
1255
+ print(f"Deleted files: {len(file_categories['deleted_files'])}")
1256
+
1257
+ # Check for unstashable files and report (always show errors)
1258
+ if unstashable_files:
1259
+ print(f"\nError: Cannot stash changelist '{name}' "
1260
+ f"because these files are not stashable:")
1261
+
1262
+ if file_categories['clean_files']:
1263
+ print(" Clean files (no changes):")
1264
+ for file_path in file_categories['clean_files']:
1265
+ print(f" {file_path}")
1266
+
1267
+ if file_categories['staged_modifications']:
1268
+ print(" Files with only staged changes "
1269
+ "(use 'git stash --staged' manually if needed):")
1270
+ for file_path in file_categories['staged_modifications']:
1271
+ print(f" {file_path}")
1272
+
1273
+ other_unstashable = [f for f in unstashable_files
1274
+ if f not in file_categories['clean_files']
1275
+ and f not in file_categories['staged_modifications']]
1276
+ if other_unstashable:
1277
+ print(" Other non-stashable files:")
1278
+ for file_path in other_unstashable:
1279
+ print(f" {file_path}")
1280
+
1281
+ print("\nStashable files must have:")
1282
+ print(" - Unstaged changes (modified/deleted)")
1283
+ print(" - Be newly added to index")
1284
+ print(" - Be untracked (if explicitly in changelist)")
1285
+ return False
1286
+
1287
+ return True
1288
+
1289
+
1290
+ def clutil_prepare_files_for_git_stash(
1291
+ stashable_files: list[str],
1292
+ file_categories: dict[str, list[str]],
1293
+ git_root: Path,
1294
+ quiet: bool = False
1295
+ ) -> tuple[list[str], list[str]]:
1296
+ """
1297
+ Prepare file paths for git stash command.
1298
+
1299
+ Args:
1300
+ stashable_files: List of files to stash
1301
+ file_categories: Categorized files
1302
+ git_root: Git repository root
1303
+ quiet: If True, suppress verbose output
1304
+
1305
+ Returns:
1306
+ Tuple of (files_for_git, untracked_files_for_git)
1307
+ """
1308
+ files_for_git = []
1309
+ for stored_path in stashable_files:
1310
+ abs_path = (git_root / stored_path).resolve()
1311
+ if abs_path.exists():
1312
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
1313
+ files_for_git.append(rel_to_cwd)
1314
+ else:
1315
+ # For deleted files, we still need to pass the path
1316
+ rel_to_cwd = _relpath(git_root / stored_path, Path.cwd().resolve())
1317
+ files_for_git.append(rel_to_cwd)
1318
+
1319
+ # Handle untracked files specially
1320
+ # - they need to be added to git first for stashing
1321
+ untracked_files_for_git = []
1322
+ if file_categories['untracked']:
1323
+ if not quiet:
1324
+ print("Adding untracked files to git temporarily for stashing...")
1325
+ for stored_path in file_categories['untracked']:
1326
+ abs_path = (git_root / stored_path).resolve()
1327
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
1328
+ untracked_files_for_git.append(rel_to_cwd)
1329
+
1330
+ try:
1331
+ # Add untracked files to git index
1332
+ subprocess.run(["git", "add", "--"] + untracked_files_for_git,
1333
+ check=True)
1334
+ except subprocess.CalledProcessError as error:
1335
+ print(f"Error adding untracked files: {error}")
1336
+ raise
1337
+
1338
+ return files_for_git, untracked_files_for_git
1339
+
1340
+
1341
+ def clutil_execute_git_stash(
1342
+ name: str,
1343
+ files_for_git: list[str],
1344
+ untracked_files_for_git: list[str]
1345
+ ) -> str:
1346
+ """
1347
+ Execute the git stash command.
1348
+
1349
+ Args:
1350
+ name: Changelist name
1351
+ files_for_git: Files to stash
1352
+ untracked_files_for_git: Untracked files that were temporarily added
1353
+
1354
+ Returns:
1355
+ The stash reference if successful
1356
+
1357
+ Raises:
1358
+ subprocess.CalledProcessError: If stash fails
1359
+ """
1360
+ stash_message = clutil_create_unique_stash_message(name)
1361
+
1362
+ try:
1363
+ cmd = ["git", "stash", "push", "-m", stash_message, "--"] + files_for_git
1364
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
1365
+
1366
+ # Check if anything was actually stashed
1367
+ if "No local changes to save" in result.stderr:
1368
+ print(f"Unexpected: No changes were stashed for changelist '{name}'.")
1369
+ # If we added untracked files, we should reset them
1370
+ if untracked_files_for_git:
1371
+ try:
1372
+ subprocess.run(["git", "reset", "HEAD", "--"] +
1373
+ untracked_files_for_git, check=True)
1374
+ print("Reset temporarily added untracked files.")
1375
+ except subprocess.CalledProcessError:
1376
+ print("Warning: Could not reset temporarily added untracked files.")
1377
+ raise ValueError("No changes were stashed")
1378
+
1379
+ except subprocess.CalledProcessError as error:
1380
+ print(f"Error creating stash: {error}")
1381
+ if error.stderr:
1382
+ print(f"Git error: {error.stderr}")
1383
+
1384
+ # Cleanup: Reset any untracked files we added
1385
+ if untracked_files_for_git:
1386
+ try:
1387
+ subprocess.run(["git", "reset", "HEAD", "--"] + untracked_files_for_git,
1388
+ check=True)
1389
+ print("Reset temporarily added untracked files after stash failure.")
1390
+ except subprocess.CalledProcessError:
1391
+ print("Warning: Could not reset temporarily added untracked files.")
1392
+ raise
1393
+
1394
+ # Find the created stash
1395
+ stash_ref = clutil_find_stash_by_message_substring(f"git-cl-stash:{name}:")
1396
+ if not stash_ref:
1397
+ print("Error: Could not locate the created stash.")
1398
+ print(f"Look for a stash with message containing 'git-cl-stash:{name}:'")
1399
+ raise ValueError("Could not locate stash")
1400
+
1401
+ return stash_ref
1402
+
1403
+
1404
+ def clutil_build_stash_metadata(
1405
+ name: str,
1406
+ stash_ref: str,
1407
+ files: list[str],
1408
+ file_categories: dict[str, list[str]],
1409
+ current_branch: Optional[str]
1410
+ ) -> dict:
1411
+ """
1412
+ Build stash metadata dictionary.
1413
+
1414
+ Args:
1415
+ name: Changelist name
1416
+ stash_ref: Git stash reference
1417
+ files: Files in the changelist
1418
+ file_categories: Categorized files
1419
+ current_branch: Current Git branch
1420
+
1421
+ Returns:
1422
+ Dictionary containing stash metadata
1423
+ """
1424
+ stash_message = clutil_create_unique_stash_message(name)
1425
+
1426
+ return {
1427
+ "stash_ref": stash_ref,
1428
+ "stash_message": stash_message,
1429
+ "files": files.copy(),
1430
+ "timestamp": datetime.datetime.now().isoformat(),
1431
+ "source_branch": current_branch,
1432
+ "file_categories": {
1433
+ "unstaged_changes": file_categories['unstaged_changes'],
1434
+ "staged_additions": file_categories['staged_additions'],
1435
+ "untracked": file_categories['untracked'],
1436
+ "deleted_files": file_categories['deleted_files']
1437
+ }
1438
+ }
1439
+
1440
+
1441
+ # pylint: disable-next=too-many-arguments
1442
+ def clutil_save_stash_metadata_atomic(
1443
+ name: str,
1444
+ stash_ref: str,
1445
+ files: list[str],
1446
+ file_categories: dict[str, list[str]],
1447
+ changelists: dict[str, list[str]],
1448
+ stashes: dict[str, dict]
1449
+ ) -> None:
1450
+ """
1451
+ Atomically save stash metadata and update changelists.
1452
+
1453
+ Args:
1454
+ name: Changelist name
1455
+ stash_ref: Git stash reference
1456
+ files: Files in the changelist
1457
+ file_categories: Categorized files
1458
+ changelists: Active changelists (will be modified)
1459
+ stashes: Stashed changelists (will be modified)
1460
+
1461
+ Raises:
1462
+ Exception: If save operations fail (triggers rollback)
1463
+ """
1464
+ current_branch = clutil_get_current_branch()
1465
+
1466
+ # Build stash metadata
1467
+ stash_metadata = clutil_build_stash_metadata(
1468
+ name, stash_ref, files, file_categories, current_branch
1469
+ )
1470
+
1471
+ # Save stash metadata
1472
+ stashes[name] = stash_metadata
1473
+ clutil_save_stashes(stashes)
1474
+
1475
+ # Remove changelist from active changelists
1476
+ del changelists[name]
1477
+ clutil_save(changelists)
1478
+
1479
+
1480
+ def clutil_handle_stash_failure(
1481
+ error: Exception,
1482
+ name: str,
1483
+ stash_ref: str,
1484
+ original_changelists: dict[str, list[str]],
1485
+ stashes: dict[str, dict]
1486
+ ) -> None:
1487
+ """
1488
+ Handle failure during stash metadata save with rollback.
1489
+
1490
+ Args:
1491
+ error: The exception that occurred
1492
+ name: Changelist name
1493
+ stash_ref: Git stash reference
1494
+ original_changelists: Original changelists for rollback
1495
+ stashes: Stashed changelists dictionary
1496
+ """
1497
+ print(f"Error during atomic operation: {error}")
1498
+ print("Attempting to rollback changes...")
1499
+
1500
+ rollback_success = True
1501
+
1502
+ # Try to restore stashes metadata (remove the failed entry)
1503
+ try:
1504
+ if name in stashes:
1505
+ del stashes[name]
1506
+ clutil_save_stashes(stashes)
1507
+ print("Rolled back stash metadata.")
1508
+ else:
1509
+ print("Stash metadata was not saved, no rollback needed.")
1510
+
1511
+ except (OSError, subprocess.CalledProcessError,
1512
+ json.JSONDecodeError) as stash_rollback_error:
1513
+ print(f"Warning: Could not rollback stash metadata: {stash_rollback_error}")
1514
+ rollback_success = False
1515
+
1516
+ # Try to restore original changelists
1517
+ try:
1518
+ clutil_save(original_changelists)
1519
+ print("Rolled back changelist modifications.")
1520
+
1521
+ except (OSError, subprocess.CalledProcessError,
1522
+ json.JSONDecodeError) as changelist_rollback_error:
1523
+ print(f"Warning: Could not rollback changelist changes: "
1524
+ f"{changelist_rollback_error}")
1525
+ rollback_success = False
1526
+
1527
+ # Try to drop the orphaned stash
1528
+ if not clutil_rollback_stash(stash_ref, name):
1529
+ rollback_success = False
1530
+
1531
+ if rollback_success:
1532
+ print(f"Rollback completed successfully. "
1533
+ f"Changelist '{name}' remains active.")
1534
+ else:
1535
+ print("Partial rollback failure. Manual cleanup may be required:")
1536
+ print(f" - Check 'git stash list' for orphaned stash: {stash_ref}")
1537
+ print(f" - Verify changelist '{name}' state with 'git cl status'")
1538
+ print(f" - Consider 'git stash drop {stash_ref}' if stash is orphaned")
1539
+
1540
+
1541
+ def clutil_unstash_all_changelists(
1542
+ stashes: dict[str, dict],
1543
+ force: bool = False
1544
+ ) -> None:
1545
+ """
1546
+ Helper function to unstash all stashed changelists.
1547
+
1548
+ Args:
1549
+ stashes: Dictionary of stashed changelists
1550
+ force: Whether to force unstash despite conflicts
1551
+ """
1552
+ if not stashes:
1553
+ print("No stashed changelists found.")
1554
+ return
1555
+
1556
+ current_branch = clutil_get_current_branch()
1557
+ if not current_branch:
1558
+ print("Error: Cannot unstash --all while in detached HEAD state.")
1559
+ print("Switch to a branch first: git checkout -b <branch-name>")
1560
+ return
1561
+
1562
+ print(f"Unstashing all changelists to branch '{current_branch}':")
1563
+ success_count = 0
1564
+
1565
+ # Sort by timestamp (oldest first) for predictable order
1566
+ stash_items = list(stashes.items())
1567
+ stash_items.sort(key=lambda x: x[1].get('timestamp', ''))
1568
+
1569
+ for stash_name, _ in stash_items:
1570
+ print(f"\n--- Unstashing '{stash_name}' ---")
1571
+ # Create a temporary args object for individual unstash
1572
+ temp_args = argparse.Namespace()
1573
+ temp_args.name = stash_name
1574
+ temp_args.force = force
1575
+ temp_args.all = False
1576
+
1577
+ try:
1578
+ # Note: cl_unstash is called later in the module
1579
+ cl_unstash(temp_args)
1580
+ success_count += 1
1581
+ except (OSError,
1582
+ subprocess.CalledProcessError, json.JSONDecodeError) as error:
1583
+ print(f"Failed to unstash '{stash_name}': {error}")
1584
+ continue
1585
+
1586
+ print(f"\nUnstashed {success_count}/{len(stash_items)} "
1587
+ f"changelists successfully.")
1588
+
1589
+
1590
+ def clutil_resolve_unstash_name(name: str) -> tuple[str, str]:
1591
+ """
1592
+ Resolve changelist name handling both "name" and "name_stashed" formats.
1593
+
1594
+ Args:
1595
+ name: The changelist name (with or without _stashed suffix)
1596
+
1597
+ Returns:
1598
+ Tuple of (base_name, stash_key)
1599
+ """
1600
+ if name.endswith("_stashed"):
1601
+ base_name = name[:-8] # Remove "_stashed" suffix
1602
+ stash_key = base_name
1603
+ else:
1604
+ base_name = name
1605
+ stash_key = name
1606
+
1607
+ return base_name, stash_key
1608
+
1609
+
1610
+ def clutil_validate_unstash_preconditions(
1611
+ base_name: str,
1612
+ stash_key: str,
1613
+ stashes: dict[str, dict],
1614
+ changelists: dict[str, list[str]]
1615
+ ) -> Optional[str]:
1616
+ """
1617
+ Validate preconditions for unstashing a changelist.
1618
+
1619
+ Args:
1620
+ base_name: The base changelist name
1621
+ stash_key: The key in stashes dictionary
1622
+ stashes: Dictionary of stashed changelists
1623
+ changelists: Dictionary of active changelists
1624
+
1625
+ Returns:
1626
+ Error message if validation fails, None if all checks pass
1627
+ """
1628
+ if stash_key not in stashes:
1629
+ error_msg = f"No stashed changelist found for '{base_name}'."
1630
+ available = list(stashes.keys())
1631
+ if available:
1632
+ error_msg += "\nAvailable stashed changelists:"
1633
+ for stashed_name in available:
1634
+ error_msg += f"\n {stashed_name}"
1635
+ return error_msg
1636
+
1637
+ # Check if target changelist name already exists
1638
+ if base_name in changelists and changelists[base_name]:
1639
+ return (f"Error: Changelist '{base_name}' already exists with files.\n"
1640
+ "Please delete or rename the existing changelist first.")
1641
+
1642
+ return None
1643
+
1644
+
1645
+ def clutil_check_branch_workflow(
1646
+ base_name: str,
1647
+ source_branch: Optional[str],
1648
+ force: bool
1649
+ ) -> Optional[str]:
1650
+ """
1651
+ Check branch workflow requirements for unstashing.
1652
+
1653
+ Args:
1654
+ base_name: The changelist name
1655
+ source_branch: The branch where changelist was stashed from
1656
+ force: Whether to force unstash despite warnings
1657
+
1658
+ Returns:
1659
+ Error message if check fails, None if checks pass
1660
+ """
1661
+ if force:
1662
+ return None
1663
+
1664
+ current_branch = clutil_get_current_branch()
1665
+
1666
+ if not current_branch:
1667
+ return ("Error: Cannot unstash while in detached HEAD state.\n"
1668
+ "Create a new branch first: git checkout -b feature-branch\n"
1669
+ f"Or use --force to unstash anyway: git cl unstash {base_name} --force")
1670
+
1671
+ # Allow restoring to original branch, but encourage new branches
1672
+ if source_branch and current_branch == source_branch:
1673
+ print(f"Note: Unstashing '{base_name}' back to original branch "
1674
+ f"'{current_branch}'")
1675
+ print("This is allowed but consider using a new branch for feature work.")
1676
+ else:
1677
+ # Check if working directory is clean (recommended for branch workflow)
1678
+ try:
1679
+ git_status = subprocess.run(["git", "status", "--porcelain"],
1680
+ capture_output=True, text=True, check=True)
1681
+ if git_status.stdout.strip():
1682
+ return (f"Warning: Working directory is not clean on branch '{current_branch}'\n"
1683
+ "The branch workflow works best with a clean working directory.\n"
1684
+ "Consider committing or stashing current changes first.\n"
1685
+ "Or use --force to unstash anyway: git cl unstash "
1686
+ f"{base_name} --force")
1687
+ except subprocess.CalledProcessError:
1688
+ pass # git status failed, continue anyway
1689
+
1690
+ return None
1691
+
1692
+
1693
+ def clutil_check_and_report_conflicts(
1694
+ files: list[str],
1695
+ git_root: Path,
1696
+ base_name: str,
1697
+ force: bool,
1698
+ quiet: bool = False
1699
+ ) -> bool:
1700
+ """
1701
+ Check for conflicts and report them to user.
1702
+
1703
+ Args:
1704
+ files: List of files to unstash
1705
+ git_root: Git repository root
1706
+ base_name: Changelist name
1707
+ force: Whether to force despite conflicts
1708
+ quiet: If True, suppress verbose output
1709
+
1710
+ Returns:
1711
+ True if unstash can proceed, False otherwise
1712
+ """
1713
+ if force:
1714
+ return True
1715
+
1716
+ real_conflicts, file_status_info = (
1717
+ clutil_check_unstash_conflicts_optimized(files, git_root))
1718
+
1719
+ if real_conflicts:
1720
+ print(f"Cannot unstash '{base_name}' due to conflicts:")
1721
+ for file_path in real_conflicts:
1722
+ status_desc = file_status_info[file_path]
1723
+ print(f" {file_path} ({status_desc})")
1724
+
1725
+ clutil_suggest_workflow_actions(real_conflicts, file_status_info)
1726
+ print(f"\nAfter resolving conflicts, retry: git cl unstash {base_name}")
1727
+ print(f"Or use --force to attempt automatic merge:"
1728
+ f"git cl unstash {base_name} --force")
1729
+ return False
1730
+
1731
+ # Show informational status for non-conflicting files (unless quiet)
1732
+ if not quiet:
1733
+ safe_files = [f for f in files if f not in real_conflicts]
1734
+ if safe_files:
1735
+ staged_files = []
1736
+ clean_files = []
1737
+ missing_files = []
1738
+ for file_path in safe_files:
1739
+ status_desc = file_status_info.get(file_path, "")
1740
+ if "staged" in status_desc:
1741
+ staged_files.append(file_path)
1742
+ elif "clean" in status_desc:
1743
+ clean_files.append(file_path)
1744
+ elif "missing" in status_desc:
1745
+ missing_files.append(file_path)
1746
+
1747
+ status_summary = []
1748
+ if clean_files:
1749
+ status_summary.append(f"{len(clean_files)} clean")
1750
+ if missing_files:
1751
+ status_summary.append(f"{len(missing_files)} to be restored")
1752
+ if staged_files:
1753
+ status_summary.append(f"{len(staged_files)} with staged changes")
1754
+
1755
+ if status_summary:
1756
+ print(f"Ready to unstash {len(safe_files)} "
1757
+ f"files: {', '.join(status_summary)}")
1758
+
1759
+ return True
1760
+
1761
+
1762
+ def clutil_verify_and_update_stash_ref(
1763
+ base_name: str,
1764
+ old_stash_ref: str
1765
+ ) -> Optional[str]:
1766
+ """
1767
+ Verify stash still exists and update reference if needed.
1768
+
1769
+ Checks if the stash for the given changelist still exists and updates
1770
+ the stash reference if it has changed (due to other stash operations).
1771
+ The updated reference is persisted to the stash metadata file to prevent
1772
+ future reference drift.
1773
+
1774
+ Args:
1775
+ base_name: Changelist name
1776
+ old_stash_ref: Previously stored stash reference
1777
+
1778
+ Returns:
1779
+ Updated stash reference or None if stash doesn't exist
1780
+ """
1781
+ current_stash_ref = clutil_find_stash_by_message_substring(f"git-cl-stash:"
1782
+ f"{base_name}:")
1783
+
1784
+ if not current_stash_ref:
1785
+ print(f"Error: Stash for changelist '{base_name}' no longer exists.")
1786
+ print("The stash may have been manually dropped. Available stashes:")
1787
+ try:
1788
+ stash_list = subprocess.check_output(["git", "stash", "list"],
1789
+ text=True)
1790
+ if stash_list.strip():
1791
+ for line in stash_list.strip().split('\n'):
1792
+ print(f" {line}")
1793
+ else:
1794
+ print(" (no stashes found)")
1795
+ except subprocess.CalledProcessError:
1796
+ print(" (could not list stashes)")
1797
+ return None
1798
+
1799
+ # Update stash reference if needed
1800
+ if current_stash_ref != old_stash_ref:
1801
+ print(f"Note: Stash reference updated from {old_stash_ref} "
1802
+ f"to {current_stash_ref}")
1803
+
1804
+ # Persist the updated reference to prevent future drift
1805
+ stashes = clutil_load_stashes()
1806
+ if base_name in stashes:
1807
+ stashes[base_name]["stash_ref"] = current_stash_ref
1808
+ clutil_save_stashes(stashes)
1809
+
1810
+ return current_stash_ref
1811
+
1812
+
1813
+ def clutil_apply_stash(stash_ref: str, base_name: str, quiet: bool = False) -> bool:
1814
+ """
1815
+ Apply the stash using git stash pop.
1816
+
1817
+ Args:
1818
+ stash_ref: Git stash reference
1819
+ base_name: Changelist name for error messages
1820
+ quiet: If True, suppress verbose output
1821
+
1822
+ Returns:
1823
+ True if successful, False if conflicts occurred
1824
+ """
1825
+ if not quiet:
1826
+ current_branch = clutil_get_current_branch()
1827
+ branch_info = f" to branch '{current_branch}'" if current_branch else ""
1828
+ print(f"Unstashing changelist '{base_name}'{branch_info}...")
1829
+
1830
+ try:
1831
+ result = subprocess.run(["git", "stash", "pop", stash_ref],
1832
+ capture_output=True, text=True, check=True)
1833
+
1834
+ # Handle merge conflicts
1835
+ if "CONFLICT" in result.stdout or "CONFLICT" in result.stderr:
1836
+ print(f"Merge conflicts occurred while unstashing '{base_name}'.")
1837
+ print("This is unexpected in the typical "
1838
+ "stash-branch-unstash workflow.")
1839
+ print("\nTo resolve:")
1840
+ print(" 1. Resolve conflicts in the affected files")
1841
+ print(" 2. Stage resolved files: git add <resolved-files>")
1842
+ print(f" 3. Recreate changelist: git cl add {base_name} <files>")
1843
+ return False
1844
+
1845
+ return True
1846
+
1847
+ except subprocess.CalledProcessError as error:
1848
+ print(f"Error unstashing '{base_name}': {error}")
1849
+ if "CONFLICT" in str(error.stderr):
1850
+ print("Merge conflicts occurred. To resolve:")
1851
+ print(" 1. Check 'git status' for conflicted files")
1852
+ print(" 2. Resolve conflicts and stage files")
1853
+ print(" 3. Recreate changelist manually")
1854
+ else:
1855
+ print("This may indicate the working directory is not clean.")
1856
+ print("Ensure you're on a clean branch before unstashing.")
1857
+ return False
1858
+
1859
+
1860
+ def clutil_process_unstash_result(
1861
+ base_name: str,
1862
+ files: list[str],
1863
+ current_branch: Optional[str]
1864
+ ) -> None:
1865
+ """
1866
+ Process and display results after successful unstash.
1867
+
1868
+ Args:
1869
+ base_name: Changelist name
1870
+ files: List of files that were restored
1871
+ current_branch: Current Git branch
1872
+ """
1873
+ success_msg = f" Successfully unstashed changelist '{base_name}'"
1874
+ if current_branch:
1875
+ success_msg += f" to branch '{current_branch}'"
1876
+ print(success_msg)
1877
+ print(f" Restored {len(files)} files to working directory")
1878
+ print(" Ready to work on your feature!")
1879
+
1880
+ # Show the files that were restored
1881
+ if len(files) <= 10:
1882
+ print("Files restored:")
1883
+ for file_path in files:
1884
+ print(f" {file_path}")
1885
+ else:
1886
+ print(f"Files restored: {files[0]}, {files[1]}, "
1887
+ f"... and {len(files) - 2} others")
1888
+
1889
+
1890
+ def clutil_update_unstash_metadata(
1891
+ base_name: str,
1892
+ stash_key: str,
1893
+ files: list[str],
1894
+ changelists: dict[str, list[str]],
1895
+ stashes: dict[str, dict],
1896
+ quiet: bool = False
1897
+ ) -> None:
1898
+ """
1899
+ Update metadata after successful unstash.
1900
+
1901
+ Args:
1902
+ base_name: Changelist name
1903
+ stash_key: Key in stashes dictionary
1904
+ files: List of files in the changelist
1905
+ changelists: Active changelists dictionary (will be modified)
1906
+ stashes: Stashed changelists dictionary (will be modified)
1907
+ quiet: If True, suppress verbose output
1908
+ """
1909
+ try:
1910
+ changelists[base_name] = files.copy()
1911
+ clutil_save(changelists)
1912
+
1913
+ del stashes[stash_key]
1914
+ clutil_save_stashes(stashes)
1915
+
1916
+ if not quiet:
1917
+ current_branch = clutil_get_current_branch()
1918
+ clutil_process_unstash_result(base_name, files, current_branch)
1919
+
1920
+ except (OSError, subprocess.CalledProcessError,
1921
+ json.JSONDecodeError) as metadata_error:
1922
+ print(f"Warning: Unstash succeeded but metadata update failed: "
1923
+ f"{metadata_error}")
1924
+ print(f"Manually recreate the changelist: git cl add "
1925
+ f"{base_name} <files>")
1926
+
1927
+
1928
+ def clutil_prepare_stash_files(
1929
+ files: list[str],
1930
+ git_root: Path
1931
+ ) -> tuple[list[tuple[str, Path]], list[str]]:
1932
+ """
1933
+ Prepare files for stashing by checking existence.
1934
+
1935
+ Args:
1936
+ files: List of file paths from changelist
1937
+ git_root: Git repository root
1938
+
1939
+ Returns:
1940
+ Tuple of (existing_files, missing_files)
1941
+ """
1942
+ missing_files = []
1943
+ existing_files = []
1944
+
1945
+ for stored_path in files:
1946
+ abs_path = (git_root / stored_path).resolve()
1947
+ if not abs_path.exists():
1948
+ missing_files.append(stored_path)
1949
+ else:
1950
+ existing_files.append((stored_path, abs_path))
1951
+
1952
+ return existing_files, missing_files
1953
+
1954
+
1955
+ def clutil_print_stash_success(
1956
+ name: str,
1957
+ stashable_files: list[str],
1958
+ file_categories: dict[str, list[str]],
1959
+ current_branch: Optional[str]
1960
+ ) -> None:
1961
+ """
1962
+ Print success message after stashing.
1963
+
1964
+ Args:
1965
+ name: Changelist name
1966
+ stashable_files: List of files that were stashed
1967
+ file_categories: Categorized files
1968
+ current_branch: Current Git branch
1969
+ """
1970
+ success_msg = f" Successfully stashed changelist '{name}'"
1971
+ if current_branch:
1972
+ success_msg += f" from branch '{current_branch}'"
1973
+ print(success_msg)
1974
+ print(f" Stashed {len(stashable_files)} file(s):")
1975
+
1976
+ # Show breakdown of what was stashed
1977
+ categories_to_show = {
1978
+ 'unstaged_changes': 'unstaged changes',
1979
+ 'staged_additions': 'newly added',
1980
+ 'untracked': 'untracked',
1981
+ 'deleted_files': 'deleted'
1982
+ }
1983
+
1984
+ for category_key, category_display in categories_to_show.items():
1985
+ files_list = file_categories.get(category_key, [])
1986
+ if files_list:
1987
+ print(f" {len(files_list)} with {category_display}")
1988
+
1989
+ print(" Working directory is now clean for this changelist")
1990
+
1991
+
1992
+ def clutil_show_active_changelists(
1993
+ changelists: dict[str, list[str]],
1994
+ selected_names: Optional[set[str]],
1995
+ status_map: dict[str, str],
1996
+ git_root: Path,
1997
+ use_color: bool
1998
+ ) -> tuple[bool, set[str]]:
1999
+ """
2000
+ Display active changelists and return assigned files.
2001
+
2002
+ Args:
2003
+ changelists: Active changelists
2004
+ selected_names: Set of names to filter by (None = show all)
2005
+ status_map: Git status mapping
2006
+ git_root: Git repository root
2007
+ use_color: Whether to use colored output
2008
+
2009
+ Returns:
2010
+ Tuple of (shown_any, assigned_files)
2011
+ """
2012
+ assigned_files = set()
2013
+ shown_any = False
2014
+
2015
+ for cl_name, files in changelists.items():
2016
+ # Skip any remaining stashed entries (cleanup from old implementation)
2017
+ if cl_name.endswith('_stashed'):
2018
+ continue
2019
+
2020
+ if selected_names is not None and cl_name not in selected_names:
2021
+ continue
2022
+
2023
+ if files: # Only show non-empty changelists
2024
+ print(f"{cl_name}:")
2025
+ shown_any = True
2026
+ for file in files:
2027
+ print(clutil_format_file_status(file, status_map, git_root, use_color))
2028
+
2029
+ # Track assigned files
2030
+ for f in files:
2031
+ try:
2032
+ abs_path = (git_root / f).resolve()
2033
+ rel_path = abs_path.relative_to(git_root).as_posix()
2034
+ assigned_files.add(rel_path)
2035
+ except (ValueError, OSError) as error:
2036
+ print(f"Error resolving path: {error}")
2037
+
2038
+ return shown_any, assigned_files
2039
+
2040
+
2041
+ def clutil_show_stashed_changelists(stashes: dict[str, dict]) -> None:
2042
+ """
2043
+ Display stashed changelists section.
2044
+
2045
+ Args:
2046
+ stashes: Dictionary of stashed changelists
2047
+ """
2048
+ print("Stashed Changelists:")
2049
+
2050
+ # Sort by timestamp (most recent first)
2051
+ stashed_items = []
2052
+ for stash_name, stash_data in stashes.items():
2053
+ timestamp = stash_data.get('timestamp', '')
2054
+ file_count = len(stash_data.get('files', []))
2055
+ stashed_items.append((stash_name, file_count, timestamp))
2056
+
2057
+ # Sort by timestamp descending (most recent first)
2058
+ stashed_items.sort(key=lambda x: x[2], reverse=True)
2059
+
2060
+ for stash_name, file_count, timestamp in stashed_items:
2061
+ # Format timestamp for display
2062
+ try:
2063
+ dt = datetime.datetime.fromisoformat(timestamp)
2064
+ formatted_time = dt.strftime("%Y-%m-%d %H:%M")
2065
+ except (ValueError, AttributeError):
2066
+ formatted_time = "unknown time"
2067
+
2068
+ file_word = "file" if file_count == 1 else "files"
2069
+ print(f" {stash_name} ({file_count} {file_word}, {formatted_time})")
2070
+
2071
+
2072
+ def clutil_validate_unstash_environment(
2073
+ base_name: str,
2074
+ stash_key: str,
2075
+ stashes: dict[str, dict],
2076
+ changelists: dict[str, list[str]],
2077
+ force: bool
2078
+ ) -> tuple[Optional[str], Optional[dict]]:
2079
+ """
2080
+ Validate all preconditions for unstashing.
2081
+
2082
+ Args:
2083
+ base_name: Base changelist name
2084
+ stash_key: Key in stashes dictionary
2085
+ stashes: Stashed changelists
2086
+ changelists: Active changelists
2087
+ force: Whether to force despite warnings
2088
+
2089
+ Returns:
2090
+ Tuple of (error_message, stash_data) - error_message is None if valid
2091
+ """
2092
+ # Validate basic preconditions
2093
+ error_msg = clutil_validate_unstash_preconditions(
2094
+ base_name, stash_key, stashes, changelists
2095
+ )
2096
+ if error_msg:
2097
+ return error_msg, None
2098
+
2099
+ stash_data = stashes[stash_key]
2100
+ source_branch = clutil_get_stash_source_branch(stash_data)
2101
+
2102
+ # Check branch workflow requirements
2103
+ error_msg = clutil_check_branch_workflow(base_name, source_branch, force)
2104
+ if error_msg:
2105
+ return error_msg, None
2106
+
2107
+ return None, stash_data
2108
+
2109
+
2110
+ # =============================================================================
2111
+ # BRANCH WORKFLOW UTILITIES (branch-specific utilities)
2112
+ # =============================================================================
2113
+
2114
+ def clutil_validate_branch_preconditions(
2115
+ changelist_name: str, changelists: dict, stashes: dict) -> str:
2116
+ """Validate preconditions for branch creation workflow."""
2117
+ if changelist_name not in changelists:
2118
+ error_msg = f"Error: Changelist '{changelist_name}' not found."
2119
+ available = [name for name in changelists.keys() if changelists[name]]
2120
+ if available:
2121
+ error_msg += "\nAvailable active changelists:"
2122
+ for name in available:
2123
+ error_msg += f"\n {name}"
2124
+ return error_msg
2125
+
2126
+ if not changelists[changelist_name]:
2127
+ return (f"Error: Changelist '{changelist_name}' is empty.\n"
2128
+ f"Add files with: git cl add {changelist_name} <files>")
2129
+
2130
+ if changelist_name in stashes:
2131
+ return (f"Error: Changelist '{changelist_name}' is already stashed.\n"
2132
+ f"Use 'git cl unstash {changelist_name}' to restore it first.")
2133
+
2134
+ return ""
2135
+
2136
+
2137
+ def clutil_check_branch_exists(branch_name: str) -> bool:
2138
+ """Check if a branch already exists."""
2139
+ try:
2140
+ result = subprocess.run(["git", "show-ref", "--verify",
2141
+ f"refs/heads/{branch_name}"],
2142
+ capture_output=True, text=True, check=False)
2143
+ return result.returncode == 0
2144
+ except subprocess.CalledProcessError:
2145
+ return False
2146
+
2147
+
2148
+ def clutil_check_unassigned_changes(changelists: dict,
2149
+ git_root: Path) -> list[str]:
2150
+ """Check for uncommitted changes not in any changelist."""
2151
+ status_map = clutil_get_file_status_map(show_all=True)
2152
+
2153
+ # Get all files assigned to changelists
2154
+ assigned_files = set()
2155
+ for files in changelists.values():
2156
+ for file_path in files:
2157
+ try:
2158
+ abs_path = (git_root / file_path).resolve()
2159
+ rel_path = abs_path.relative_to(git_root).as_posix()
2160
+ assigned_files.add(rel_path)
2161
+ except (ValueError, OSError):
2162
+ continue
2163
+
2164
+ # Check for unassigned changes (ignore untracked files)
2165
+ return [file_path for file_path, status in status_map.items()
2166
+ if file_path not in assigned_files and status != "??"]
2167
+
2168
+
2169
+ def clutil_execute_stash_all() -> None:
2170
+ """Execute stash all operation."""
2171
+ temp_args = argparse.Namespace()
2172
+ temp_args.all = True
2173
+ cl_stash(temp_args)
2174
+
2175
+
2176
+ def clutil_create_branch(branch_name: str,
2177
+ base_branch: str, current_branch: str) -> None:
2178
+ """Create new branch from base."""
2179
+ if base_branch != current_branch:
2180
+ # Create branch from specified base
2181
+ subprocess.run(["git", "checkout", "-b", branch_name, base_branch],
2182
+ check=True)
2183
+ else:
2184
+ # Create branch from current HEAD
2185
+ subprocess.run(["git", "checkout", "-b", branch_name], check=True)
2186
+
2187
+
2188
+ def clutil_unstash_changelist(changelist_name: str) -> None:
2189
+ """Unstash specific changelist."""
2190
+ temp_unstash_args = argparse.Namespace()
2191
+ temp_unstash_args.name = changelist_name
2192
+ temp_unstash_args.force = False
2193
+ temp_unstash_args.all = False
2194
+ cl_unstash(temp_unstash_args, quiet=True) # Add quiet=True here
2195
+
2196
+
2197
+ def clutil_handle_branch_creation_failure() -> None:
2198
+ """Handle failure during branch creation by restoring stashes."""
2199
+ try:
2200
+ temp_unstash_args = argparse.Namespace()
2201
+ temp_unstash_args.all = True
2202
+ temp_unstash_args.force = True
2203
+ cl_unstash(temp_unstash_args)
2204
+ except (OSError, subprocess.CalledProcessError,
2205
+ json.JSONDecodeError) as restore_error:
2206
+ print(f"Error restoring stashes: {restore_error}")
2207
+ print("You may need to manually unstash: git cl unstash --all")
2208
+
2209
+
2210
+ # =============================================================================
2211
+ # CLI COMMANDS
2212
+ # =============================================================================
2213
+
2214
+
2215
+ def cl_add(args: argparse.Namespace) -> None:
2216
+ """
2217
+ Adds one or more files to the specified changelist, creating it if needed.
2218
+ Prevents adding files that are in stashed changelists.
2219
+ """
2220
+ if not clutil_validate_name(args.name):
2221
+ print(
2222
+ f"Error: Invalid changelist name '{args.name}'. Names cannot "
2223
+ "contain special characters or be Git reserved words.")
2224
+ return
2225
+
2226
+ changelists = clutil_load()
2227
+ stashes = clutil_load_stashes()
2228
+ git_root = clutil_get_git_root()
2229
+
2230
+ files = []
2231
+ for file in args.files:
2232
+ sanitized = clutil_sanitize_path(file, git_root)
2233
+ if sanitized:
2234
+ # Check if file exists and warn if not
2235
+ abs_path = (git_root / sanitized).resolve()
2236
+ if not abs_path.exists():
2237
+ print(f"Warning: File '{file}' does not exist.")
2238
+ files.append(sanitized)
2239
+ else:
2240
+ print(f"Warning: Skipping invalid or unsafe path: '{file}'")
2241
+
2242
+ # Check if any files are in stashed changelists
2243
+ blocked_files = []
2244
+ for file_path in files:
2245
+ for stash_name, stash_data in stashes.items():
2246
+ if file_path in stash_data['files']:
2247
+ blocked_files.append((file_path, stash_name))
2248
+
2249
+ if blocked_files:
2250
+ print("Error: Cannot add files that are in stashed changelists:")
2251
+ for file_path, stash_name in blocked_files:
2252
+ print(f" {file_path} (in stashed changelist '{stash_name}')")
2253
+ print("Unstash the changelist first if you want to modify it.")
2254
+ return
2255
+
2256
+ if args.name not in changelists:
2257
+ changelists[args.name] = []
2258
+
2259
+ for file in files:
2260
+ # Remove from other active changelists
2261
+ for clist in changelists.values():
2262
+ if file in clist:
2263
+ clist.remove(file)
2264
+ if file not in changelists[args.name]:
2265
+ changelists[args.name].append(file)
2266
+
2267
+ clutil_save(changelists)
2268
+ print(f"Added to '{args.name}': {files}")
2269
+
2270
+
2271
+ def cl_stage(args: argparse.Namespace) -> None:
2272
+ """
2273
+ Stages all tracked files in the given changelist and optionally
2274
+ deletes the changelist.
2275
+
2276
+ Args:
2277
+ args: argparse.Namespace with 'name' and 'delete' attributes.
2278
+ """
2279
+ changelists = clutil_load()
2280
+ name = args.name
2281
+ if name not in changelists:
2282
+ print(f"Changelist '{name}' not found.")
2283
+ return
2284
+
2285
+ git_root = clutil_get_git_root()
2286
+ to_stage = []
2287
+
2288
+ for stored_path in changelists[name]:
2289
+ # Convert stored path (relative to git root) to absolute path
2290
+ abs_path = (git_root / stored_path).resolve()
2291
+
2292
+ if not abs_path.exists():
2293
+ print(f"Warning: '{stored_path}' does not exist.")
2294
+ continue
2295
+
2296
+ # Convert to path relative to current working directory for Git command
2297
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
2298
+
2299
+ # Check if this file is tracked (not untracked)
2300
+ if not clutil_is_file_untracked(stored_path, git_root):
2301
+ to_stage.append(rel_to_cwd)
2302
+
2303
+ if not to_stage:
2304
+ print(f"No tracked files to stage in changelist '{name}'.")
2305
+ return
2306
+
2307
+ try:
2308
+ subprocess.run(["git", "add", "--"] + to_stage, check=True)
2309
+ print(f"Staged tracked files from changelist '{name}':")
2310
+ for file in to_stage:
2311
+ print(f" {file}")
2312
+ except subprocess.CalledProcessError as error:
2313
+ print(f"Error staging files: {error}")
2314
+ return
2315
+
2316
+ # Only delete if --delete flag is set
2317
+ if args.delete:
2318
+ del changelists[name]
2319
+ clutil_save(changelists)
2320
+ print(f"Deleted changelist '{name}'")
2321
+
2322
+
2323
+ def cl_unstage(args: argparse.Namespace) -> None:
2324
+ """
2325
+ Unstages all staged files in the specified changelist, moving them back
2326
+ to the working directory as unstaged changes.
2327
+
2328
+ Args:
2329
+ args: argparse.Namespace with 'name' and 'keep' attributes.
2330
+ """
2331
+ changelists = clutil_load()
2332
+ name = args.name
2333
+ if name not in changelists:
2334
+ print(f"Changelist '{name}' not found.")
2335
+ return
2336
+
2337
+ git_root = clutil_get_git_root()
2338
+ to_unstage = []
2339
+
2340
+ # Get current status to identify staged files
2341
+ status_map = clutil_get_file_status_map(show_all=True)
2342
+
2343
+ for stored_path in changelists[name]:
2344
+ # Convert stored path (relative to git root) to absolute path
2345
+ abs_path = (git_root / stored_path).resolve()
2346
+
2347
+ if not abs_path.exists():
2348
+ print(f"Warning: '{stored_path}' does not exist.")
2349
+ continue
2350
+
2351
+ # Convert to path relative to current working directory for Git command
2352
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
2353
+
2354
+ # Check if this file has staged changes
2355
+ rel_to_git_root = abs_path.relative_to(git_root).as_posix()
2356
+ status = status_map.get(rel_to_git_root, " ")
2357
+
2358
+ # File has staged changes if first character is not space
2359
+ if status[0] != " ":
2360
+ to_unstage.append(rel_to_cwd)
2361
+
2362
+ if not to_unstage:
2363
+ print(f"No staged files to unstage in changelist '{name}'.")
2364
+ return
2365
+
2366
+ try:
2367
+ # Use git reset HEAD to unstage files
2368
+ subprocess.run(["git", "reset", "HEAD", "--"] + to_unstage, check=True)
2369
+ print(f"Unstaged files from changelist '{name}':")
2370
+ for file in to_unstage:
2371
+ print(f" {file}")
2372
+ except subprocess.CalledProcessError as error:
2373
+ print(f"Error unstaging files: {error}")
2374
+ return
2375
+
2376
+ # Only delete if --delete flag is set
2377
+ if args.delete:
2378
+ del changelists[name]
2379
+ clutil_save(changelists)
2380
+ print(f"Deleted changelist '{name}'")
2381
+
2382
+
2383
+ def cl_status(args: argparse.Namespace) -> None:
2384
+ """
2385
+ Displays git status grouped by changelist membership,
2386
+ including stashed changelists.
2387
+
2388
+ Shows active changelists first, then a separate "Stashed Changelists" section,
2389
+ then unassigned files. Filtering by changelist names applies only to active
2390
+ changelists unless --include-no-cl is specified.
2391
+ """
2392
+ selected_names = set(args.names) if args.names else None
2393
+ changelists = clutil_load()
2394
+ stashes = clutil_load_stashes()
2395
+ git_root = clutil_get_git_root()
2396
+ status_map = clutil_get_file_status_map(show_all=args.all)
2397
+
2398
+ use_color = clutil_should_use_color(args)
2399
+
2400
+ # Show active changelists
2401
+ shown_any_active, assigned_files = clutil_show_active_changelists(
2402
+ changelists, selected_names, status_map, git_root, use_color
2403
+ )
2404
+
2405
+ # Show stashed changelists section
2406
+ if stashes and (selected_names is None): # Only show when not filtering
2407
+ if shown_any_active:
2408
+ print() # Add spacing
2409
+ clutil_show_stashed_changelists(stashes)
2410
+
2411
+ # Show unassigned files
2412
+ if selected_names is None or args.include_no_cl:
2413
+ no_cl_files = [file for file in status_map if file not in assigned_files]
2414
+ if no_cl_files:
2415
+ if shown_any_active or stashes:
2416
+ print() # Add spacing
2417
+ print("No Changelist:")
2418
+ for file in sorted(no_cl_files):
2419
+ print(clutil_format_file_status(file, status_map, git_root, use_color))
2420
+
2421
+
2422
+ def cl_diff(args: argparse.Namespace) -> None:
2423
+ """
2424
+ Show git diff for one or more changelists.
2425
+
2426
+ By default, shows unstaged changes (like `git diff`).
2427
+ Use --staged for staged changes, or --both to show both.
2428
+
2429
+ Args:
2430
+ args: argparse.Namespace with 'names' list of changelists,
2431
+ and optional 'staged' and 'both' flags
2432
+ """
2433
+ changelists = clutil_load()
2434
+ git_root = clutil_get_git_root()
2435
+ diff_files = set()
2436
+
2437
+ for name in args.names:
2438
+ if name not in changelists:
2439
+ print(f"Changelist '{name}' not found.")
2440
+ continue
2441
+ for path in changelists[name]:
2442
+ abs_path = (git_root / path).resolve()
2443
+ if abs_path.exists():
2444
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
2445
+ diff_files.add(rel_to_cwd)
2446
+ else:
2447
+ print(f"Warning: File '{path}' from '{name}' does not exist.")
2448
+
2449
+ if not diff_files:
2450
+ print("No valid files to diff.")
2451
+ return
2452
+
2453
+ sorted_files = sorted(diff_files)
2454
+
2455
+ try:
2456
+ if args.both:
2457
+ # Show both unstaged and staged diffs
2458
+ print("=== Unstaged changes (working tree vs index) ===")
2459
+ subprocess.run(["git", "diff", "--"] + sorted_files, check=False)
2460
+ print("\n=== Staged changes (index vs HEAD) ===")
2461
+ subprocess.run(["git", "diff", "--staged", "--"] + sorted_files,
2462
+ check=False)
2463
+ elif args.staged:
2464
+ # Show only staged changes
2465
+ subprocess.run(["git", "diff", "--staged", "--"] + sorted_files,
2466
+ check=False)
2467
+ else:
2468
+ # Default: show unstaged changes
2469
+ subprocess.run(["git", "diff", "--"] + sorted_files, check=False)
2470
+ except subprocess.CalledProcessError as error:
2471
+ print(f"Error running git diff: {error}")
2472
+
2473
+
2474
+ def cl_checkout(args: argparse.Namespace) -> None:
2475
+ """
2476
+ Checkout (revert) files from one or more changelists to their HEAD state.
2477
+
2478
+ This discards local changes in the working directory for the specified files,
2479
+ reverting them to their last committed state. Useful for undoing unwanted
2480
+ local modifications. Optionally deletes the changelist(s) after checkout.
2481
+
2482
+ Args:
2483
+ args: argparse.Namespace with 'names' list of changelist names,
2484
+ optional 'force' flag for confirmation, and optional 'delete'
2485
+ flag to remove changelist(s) after checkout.
2486
+ """
2487
+ changelists = clutil_load()
2488
+ git_root = clutil_get_git_root()
2489
+
2490
+ # Collect all files from specified changelists
2491
+ all_files = []
2492
+ missing_changelists = []
2493
+
2494
+ for name in args.names:
2495
+ if name not in changelists:
2496
+ missing_changelists.append(name)
2497
+ continue
2498
+
2499
+ files = changelists[name]
2500
+ if not files:
2501
+ print(f"Warning: Changelist '{name}' is empty.")
2502
+ continue
2503
+
2504
+ all_files.extend(files)
2505
+
2506
+ # Report missing changelists
2507
+ if missing_changelists:
2508
+ print("Error: The following changelists were not found:")
2509
+ for name in missing_changelists:
2510
+ print(f" {name}")
2511
+ if not all_files: # No valid changelists found
2512
+ return
2513
+
2514
+ if not all_files:
2515
+ print("No files to checkout.")
2516
+ return
2517
+
2518
+ # Convert to paths relative to current working directory for git command
2519
+ checkout_files = []
2520
+ missing_files = []
2521
+
2522
+ for stored_path in all_files:
2523
+ abs_path = (git_root / stored_path).resolve()
2524
+
2525
+ if not abs_path.exists():
2526
+ missing_files.append(stored_path)
2527
+ continue
2528
+
2529
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
2530
+ checkout_files.append(rel_to_cwd)
2531
+
2532
+ # Report missing files
2533
+ if missing_files:
2534
+ print("Warning: The following files do not exist and will be skipped:")
2535
+ for file_path in missing_files:
2536
+ print(f" {file_path}")
2537
+
2538
+ if not checkout_files:
2539
+ print("No existing files to checkout.")
2540
+ return
2541
+
2542
+ # Show what will be affected and ask for confirmation unless --force
2543
+ print(f"This will discard local changes in {len(checkout_files)} file(s):")
2544
+ for file_path in checkout_files[:10]: # Show first 10 files
2545
+ print(f" {file_path}")
2546
+ if len(checkout_files) > 10:
2547
+ print(f" ... and {len(checkout_files) - 10} more files")
2548
+
2549
+ if not args.force:
2550
+ try:
2551
+ response = input("\nProceed? [y/N]: ").strip().lower()
2552
+ if response not in ('y', 'yes'):
2553
+ print("Checkout cancelled.")
2554
+ return
2555
+ except (EOFError, KeyboardInterrupt):
2556
+ print("\nCheckout cancelled.")
2557
+ return
2558
+
2559
+ # Perform the checkout
2560
+ try:
2561
+ subprocess.run(["git", "checkout", "HEAD", "--"] + checkout_files,
2562
+ check=True)
2563
+ print(f"Successfully reverted {len(checkout_files)} file(s) to HEAD state.")
2564
+
2565
+ # Show summary by changelist
2566
+ print("\nFiles reverted by changelist:")
2567
+ for name in args.names:
2568
+ if name in changelists and changelists[name]:
2569
+ reverted_count = sum(1 for f in changelists[name]
2570
+ if f in all_files and
2571
+ (git_root / f).resolve().exists())
2572
+ if reverted_count > 0:
2573
+ print(f" {name}: {reverted_count} files")
2574
+
2575
+ # Delete changelists if --delete flag is set
2576
+ if args.delete:
2577
+ deleted_any = False
2578
+ for name in args.names:
2579
+ if name in changelists:
2580
+ del changelists[name]
2581
+ print(f"Deleted changelist '{name}'")
2582
+ deleted_any = True
2583
+
2584
+ if deleted_any:
2585
+ clutil_save(changelists)
2586
+
2587
+ except subprocess.CalledProcessError as error:
2588
+ print(f"Error during checkout: {error}")
2589
+ print("Some files may not have been reverted.")
2590
+
2591
+
2592
+ def cl_remove(args: argparse.Namespace) -> None:
2593
+ """
2594
+ Removes one or more files from any changelists they are part of.
2595
+
2596
+ Args:
2597
+ args: argparse.Namespace with 'files' attribute.
2598
+ """
2599
+ changelists = clutil_load()
2600
+ removed = []
2601
+
2602
+ git_root = clutil_get_git_root()
2603
+ for file in args.files:
2604
+ sanitized = clutil_sanitize_path(file, git_root)
2605
+ if sanitized is None:
2606
+ print(f"Warning: Skipping invalid or unsafe path: '{file}'")
2607
+ continue
2608
+
2609
+ found = False
2610
+ for name, files in changelists.items():
2611
+ if sanitized in files:
2612
+ files.remove(sanitized)
2613
+ removed.append(sanitized)
2614
+ print(f"Removed '{sanitized}' from changelist '{name}'")
2615
+ found = True
2616
+ break # Stop after first match
2617
+
2618
+ if not found:
2619
+ print(f"'{file}' was not found in any changelist.")
2620
+
2621
+ if removed:
2622
+ clutil_save(changelists)
2623
+
2624
+
2625
+ def cl_delete(args: argparse.Namespace) -> None:
2626
+ """
2627
+ Deletes one or more changelists by name, or all with --all.
2628
+ """
2629
+ changelists = clutil_load()
2630
+
2631
+ if args.all and args.names:
2632
+ print("Error: Cannot specify changelist names and --all together.")
2633
+ return
2634
+
2635
+ if args.all:
2636
+ if not changelists:
2637
+ print("No changelists to delete.")
2638
+ return
2639
+ count = len(changelists)
2640
+ changelists.clear()
2641
+ clutil_save(changelists)
2642
+ print(f"Deleted all {count} changelists.")
2643
+ return
2644
+
2645
+ if not args.names:
2646
+ print("Error: No changelist names provided.")
2647
+ return
2648
+
2649
+ deleted = False
2650
+ for name in args.names:
2651
+ if name in changelists:
2652
+ del changelists[name]
2653
+ print(f"Deleted changelist '{name}'")
2654
+ deleted = True
2655
+ else:
2656
+ print(f"Changelist '{name}' not found.")
2657
+
2658
+ if deleted:
2659
+ clutil_save(changelists)
2660
+
2661
+
2662
+ def cl_commit(args: argparse.Namespace) -> None:
2663
+ """
2664
+ Commits all tracked files in the specified changelist, then optionally
2665
+ deletes it.
2666
+
2667
+ Args:
2668
+ args: argparse.Namespace with 'name', 'message'/'file',
2669
+ and 'keep' attributes.
2670
+ """
2671
+ changelists = clutil_load()
2672
+ name = args.name
2673
+ if name not in changelists:
2674
+ print(f"Changelist '{name}' not found.")
2675
+ return
2676
+
2677
+ git_root = clutil_get_git_root()
2678
+ to_commit = []
2679
+
2680
+ for stored_path in changelists[name]:
2681
+ # Convert stored path (relative to git root) to absolute path
2682
+ abs_path = (git_root / stored_path).resolve()
2683
+
2684
+ if not abs_path.exists():
2685
+ print(f"Warning: '{stored_path}' does not exist.")
2686
+ continue
2687
+
2688
+ # Convert to path relative to current working directory for Git command
2689
+ rel_to_cwd = _relpath(abs_path, Path.cwd().resolve())
2690
+
2691
+ # Check if this file is tracked (not untracked)
2692
+ if not clutil_is_file_untracked(stored_path, git_root):
2693
+ to_commit.append(rel_to_cwd)
2694
+
2695
+ if not to_commit:
2696
+ print(f"No tracked files to commit in changelist '{name}'.")
2697
+ return
2698
+
2699
+ commit_message = clutil_resolve_commit_message(args)
2700
+ if not commit_message:
2701
+ return
2702
+
2703
+ try:
2704
+ subprocess.run(["git", "commit", "-m", commit_message, "--"]
2705
+ + to_commit, check=True)
2706
+ print(f"Committed tracked files from changelist '{name}':")
2707
+ for file in to_commit:
2708
+ print(f" {file}")
2709
+ except subprocess.CalledProcessError as error:
2710
+ print(f"Error committing changelist '{name}': {error}")
2711
+ return
2712
+
2713
+ if not args.keep:
2714
+ del changelists[name]
2715
+ clutil_save(changelists)
2716
+ print(f"Deleted changelist '{name}'")
2717
+ else:
2718
+ print(f"Kept changelist '{name}'")
2719
+
2720
+
2721
+ def cl_stash(args: argparse.Namespace, quiet: bool = False) -> None:
2722
+ """
2723
+ Stash all unstaged changes for files in specified changelist(s).
2724
+ Supports --all to stash all active changelists.
2725
+ Tracks source branch for better workflow support.
2726
+
2727
+ Accepts files with:
2728
+ - Unstaged changes (modified, deleted)
2729
+ - Newly added to index (staged additions)
2730
+ - Untracked files (if explicitly in changelist)
2731
+
2732
+ Args:
2733
+ args: argparse.Namespace with 'name'
2734
+ (or None for --all), 'all' attributes.
2735
+ quiet: If True, suppress verbose output for workflow contexts
2736
+ """
2737
+ changelists = clutil_load()
2738
+ stashes = clutil_load_stashes()
2739
+
2740
+ # Handle --all flag
2741
+ if getattr(args, 'all', False):
2742
+ clutil_stash_all_changelists(changelists, quiet=quiet)
2743
+ return
2744
+
2745
+ # Handle single changelist stash
2746
+ name = args.name
2747
+
2748
+ # Validate preconditions
2749
+ error_msg = clutil_validate_stash_preconditions(name, changelists, stashes)
2750
+ if error_msg:
2751
+ print(error_msg)
2752
+ return
2753
+
2754
+ git_root = clutil_get_git_root()
2755
+ files = changelists[name]
2756
+
2757
+ # Check that files exist and categorize them
2758
+ existing_files, missing_files = clutil_prepare_stash_files(files, git_root)
2759
+
2760
+ # Get file statuses and categorize
2761
+ status_map = clutil_get_file_status_map(show_all=True)
2762
+ categorization = clutil_categorize_files_for_stash(
2763
+ existing_files, missing_files, status_map, git_root
2764
+ )
2765
+ file_categories, stashable_files, unstashable_files = categorization
2766
+
2767
+ # Report categorization and check if we can proceed (suppress in quiet mode)
2768
+ if not clutil_report_stash_categorization(file_categories,
2769
+ unstashable_files, name, quiet=quiet):
2770
+ return
2771
+
2772
+ if not stashable_files:
2773
+ if not quiet:
2774
+ print(f"No stashable files found in changelist '{name}'.")
2775
+ return
2776
+
2777
+ # Prepare files for git stash command
2778
+ current_branch = clutil_get_current_branch()
2779
+ if not quiet:
2780
+ if current_branch:
2781
+ branch_info = f" from branch '{current_branch}'"
2782
+ else:
2783
+ branch_info = " from detached HEAD"
2784
+ print(f"\nStashing {len(stashable_files)} file(s) "
2785
+ f"from changelist '{name}'{branch_info}...")
2786
+
2787
+ files_for_git, untracked_files_for_git = clutil_prepare_files_for_git_stash(
2788
+ stashable_files, file_categories, git_root, quiet=quiet
2789
+ )
2790
+
2791
+ # Execute git stash with safe directory handling
2792
+ try:
2793
+ with clutil_safe_stash_context(
2794
+ git_root, files_for_git,
2795
+ untracked_files_for_git) as (safe_files, safe_untracked_files):
2796
+ stash_ref = clutil_execute_git_stash(name, safe_files,
2797
+ safe_untracked_files)
2798
+ except (subprocess.CalledProcessError, ValueError):
2799
+ return
2800
+
2801
+ # Save metadata atomically
2802
+ original_changelists = changelists.copy()
2803
+
2804
+ try:
2805
+ clutil_save_stash_metadata_atomic(
2806
+ name, stash_ref, files, file_categories, changelists, stashes
2807
+ )
2808
+
2809
+ # Success! Print appropriate message based on context
2810
+ if not quiet:
2811
+ clutil_print_stash_success(name, stashable_files,
2812
+ file_categories, current_branch)
2813
+
2814
+ except (OSError, subprocess.CalledProcessError,
2815
+ json.JSONDecodeError) as error:
2816
+ clutil_handle_stash_failure(error, name, stash_ref,
2817
+ original_changelists, stashes)
2818
+
2819
+
2820
+ def cl_unstash(args: argparse.Namespace, quiet: bool = False) -> None:
2821
+ """
2822
+ Restore a stashed changelist to the working directory.
2823
+ Enforces clean branch workflow by default.
2824
+ Supports --all to unstash all stashed changelists.
2825
+
2826
+ Args:
2827
+ args: argparse.Namespace with 'name' (or None for --all),
2828
+ 'force', and 'all' attributes.
2829
+ quiet: If True, suppress verbose output for workflow contexts
2830
+ """
2831
+ stashes = clutil_load_stashes()
2832
+ changelists = clutil_load()
2833
+
2834
+ # Handle --all flag
2835
+ if getattr(args, 'all', False):
2836
+ clutil_unstash_all_changelists(stashes, getattr(args, 'force', False))
2837
+ return
2838
+
2839
+ # Handle single changelist unstash
2840
+ name = args.name
2841
+ base_name, stash_key = clutil_resolve_unstash_name(name)
2842
+
2843
+ # Validate environment and get stash data
2844
+ error_msg, stash_data = clutil_validate_unstash_environment(
2845
+ base_name, stash_key, stashes, changelists,
2846
+ getattr(args, 'force', False)
2847
+ )
2848
+ if error_msg:
2849
+ print(error_msg)
2850
+ return
2851
+
2852
+ stash_ref = stash_data["stash_ref"]
2853
+ files = stash_data["files"]
2854
+ git_root = clutil_get_git_root()
2855
+
2856
+ # Check for conflicts (suppress verbose output in quiet mode)
2857
+ if not clutil_check_and_report_conflicts(files, git_root, base_name,
2858
+ getattr(args, 'force', False),
2859
+ quiet=quiet):
2860
+ return
2861
+
2862
+ # Verify stash still exists and update reference
2863
+ stash_ref = clutil_verify_and_update_stash_ref(base_name, stash_ref)
2864
+ if not stash_ref:
2865
+ return
2866
+
2867
+ # Apply the stash (suppress verbose output in quiet mode)
2868
+ if not clutil_apply_stash(stash_ref, base_name, quiet=quiet):
2869
+ return
2870
+
2871
+ # Success - update metadata (suppress verbose output in quiet mode)
2872
+ clutil_update_unstash_metadata(base_name, stash_key,
2873
+ files, changelists, stashes, quiet=quiet)
2874
+
2875
+
2876
+ def cl_branch(args: argparse.Namespace) -> None:
2877
+ """
2878
+ Create a new branch with changelist workflow: stash all changelists,
2879
+ create branch, unstash specified changelist.
2880
+
2881
+ This automates the common workflow of:
2882
+ 1. Stash all active changelists to clean working directory
2883
+ 2. Create a new branch from current HEAD (or specified base)
2884
+ 3. Unstash the specified changelist to the new branch
2885
+
2886
+ Args:
2887
+ args: argparse.Namespace with 'changelist_name', optional 'branch_name',
2888
+ and optional 'from_branch' attributes.
2889
+ """
2890
+ changelists = clutil_load()
2891
+ stashes = clutil_load_stashes()
2892
+
2893
+ changelist_name = args.changelist_name
2894
+ branch_name = getattr(args, 'branch_name', None) or changelist_name
2895
+
2896
+ # Validate changelist exists and is active
2897
+ error_msg = clutil_validate_branch_preconditions(changelist_name,
2898
+ changelists, stashes)
2899
+ if error_msg:
2900
+ print(error_msg)
2901
+ return
2902
+
2903
+ # Check if branch already exists
2904
+ if clutil_check_branch_exists(branch_name):
2905
+ print(f"Error: Branch '{branch_name}' already exists.")
2906
+ print(f"Use a different branch name: git cl br "
2907
+ f"{changelist_name} <new-branch-name>")
2908
+ return
2909
+
2910
+ # Check for uncommitted changes not in any changelist
2911
+ git_root = clutil_get_git_root()
2912
+ unassigned_changes = clutil_check_unassigned_changes(changelists, git_root)
2913
+
2914
+ if unassigned_changes:
2915
+ print("Error: There are uncommitted changes not in any changelist:")
2916
+ for file_path in unassigned_changes[:10]: # Show first 10
2917
+ status_map = clutil_get_file_status_map(show_all=True)
2918
+ print(f" [{status_map[file_path]}] {file_path}")
2919
+ if len(unassigned_changes) > 10:
2920
+ print(f" ... and {len(unassigned_changes) - 10} more files")
2921
+ print("\nPlease assign these files to changelists first:")
2922
+ print(f" git cl add <changelist-name> {' '.join(unassigned_changes[:3])}")
2923
+ return
2924
+
2925
+ # Get current branch for messaging
2926
+ current_branch = clutil_get_current_branch()
2927
+ if not current_branch:
2928
+ print("Error: Cannot create branch from detached HEAD state.")
2929
+ print("Switch to a branch first: git checkout <branch-name>")
2930
+ return
2931
+
2932
+ # Determine base branch
2933
+ base_branch = getattr(args, 'from_branch', None) or current_branch
2934
+
2935
+ # Count active changelists for summary
2936
+ active_changelists = {name: files for name, files in changelists.items() if files}
2937
+
2938
+ print(f"Creating branch '{branch_name}' with changelist '{changelist_name}'")
2939
+ if len(active_changelists) > 1:
2940
+ print(f"Stashing {len(active_changelists)} active changelists...")
2941
+
2942
+ # Step 1: Stash all active changelists (quietly)
2943
+ try:
2944
+ clutil_stash_all_changelists(active_changelists, quiet=True)
2945
+ except (subprocess.CalledProcessError,
2946
+ OSError, json.JSONDecodeError) as error:
2947
+ print(f"Error during stash operation: {error}")
2948
+ print("Branch creation aborted.")
2949
+ return
2950
+
2951
+ # Step 2: Create the new branch
2952
+ try:
2953
+ clutil_create_branch(branch_name, base_branch, current_branch)
2954
+ print(f"Switched to new branch '{branch_name}'")
2955
+ except subprocess.CalledProcessError as error:
2956
+ print(f"Error creating branch: {error}")
2957
+ print("Attempting to restore stashed changelists...")
2958
+ clutil_handle_branch_creation_failure()
2959
+ return
2960
+
2961
+ # Step 3: Unstash the target changelist (quietly)
2962
+ try:
2963
+ clutil_unstash_changelist(changelist_name)
2964
+
2965
+ # Single success message
2966
+ file_count = len(changelists.get(changelist_name, []))
2967
+ print(f"Restored changelist '{changelist_name}' ({file_count} files)")
2968
+ print("Ready to work on your feature!")
2969
+
2970
+ except (subprocess.CalledProcessError,
2971
+ OSError, json.JSONDecodeError) as error:
2972
+ print(f"Error unstashing changelist: {error}")
2973
+ print(f"Branch '{branch_name}' was created but"
2974
+ "changelist restore failed.")
2975
+ print(f"Try manually: git cl unstash {changelist_name}")
2976
+
2977
+
2978
+ def cl_help(args: argparse.Namespace) -> None:
2979
+ """
2980
+ Displays the main help message using argparse's built-in formatting.
2981
+ """
2982
+ args.parser.print_help()
2983
+
2984
+
2985
+ # =============================================================================
2986
+ # MAIN ENTRY POINT
2987
+ # =============================================================================
2988
+
2989
+
2990
+ def main() -> None:
2991
+ """
2992
+ Entry point for the git-cl command-line interface.
2993
+
2994
+ Parses command-line arguments and dispatches to the appropriate
2995
+ subcommand handler (e.g., add, remove, stage, commit, etc.).
2996
+ """
2997
+
2998
+ parser = argparse.ArgumentParser(
2999
+ prog='git-cl',
3000
+ usage='git cl <command> [<args>]',
3001
+ description=(
3002
+ "git-cl: A Git subcommand for managing named changelists \n"
3003
+ " Group files into logical changelists before staging "
3004
+ "or committing.\n\n"
3005
+ " NOTE: Commit/stage commands only operate on tracked files"
3006
+ "\n untracked files in changelists are safely "
3007
+ "ignored."),
3008
+ epilog=(
3009
+ "Example usage:\n"
3010
+ " git cl add my-feature file1.py file2.py\n"
3011
+ " git cl status\n"
3012
+ " git cl commit my-feature -m 'Implement feature'\n\n"
3013
+ "See also: \n"
3014
+ " GitHub: https://github.com/BHFock/git-cl \n"
3015
+ " DOI: https://doi.org/10.5281/zenodo.18722077"),
3016
+ formatter_class=argparse.RawTextHelpFormatter,
3017
+ add_help=False)
3018
+
3019
+ parser.add_argument(
3020
+ '--version',
3021
+ action='version',
3022
+ version=f'git-cl {__version__}',
3023
+ help=argparse.SUPPRESS)
3024
+
3025
+ subparsers = parser.add_subparsers(dest='command', title='Commands')
3026
+
3027
+ # ADD
3028
+ add_parser = subparsers.add_parser('add', aliases=['a'],
3029
+ help='Add files to a named changelist',
3030
+ description=(
3031
+ "Add one or more files to a named "
3032
+ "changelist. If the changelist "
3033
+ "does not exist, it will be "
3034
+ "created automatically."))
3035
+ add_parser.add_argument('name', metavar='CHANGELIST',
3036
+ help='Name of the changelist to add files to')
3037
+ add_parser.add_argument('files', metavar='FILE', nargs='+',
3038
+ help=("One or more files to include "
3039
+ "in the changelist"))
3040
+ add_parser.set_defaults(func=cl_add)
3041
+
3042
+ # REMOVE
3043
+ remove_parser = subparsers.add_parser('remove', aliases=['r', 'rm'],
3044
+ help=("Remove files from any "
3045
+ "changelist"),
3046
+ description=("Removes files from "
3047
+ "any changelists they "
3048
+ "belong to."))
3049
+ remove_parser.add_argument('files', metavar='FILE', nargs='+',
3050
+ help=("One or more files to remove "
3051
+ "from changelists"))
3052
+ remove_parser.set_defaults(func=cl_remove)
3053
+
3054
+ # DELETE
3055
+ delete_parser = subparsers.add_parser('delete', aliases=['del'],
3056
+ help=("Delete one or more "
3057
+ "changelists"),
3058
+ description=(
3059
+ "Delete one or more changelists "
3060
+ "by name, or remove all with "
3061
+ "--all."))
3062
+ delete_parser.add_argument('names', metavar='CHANGELIST', nargs='*',
3063
+ help=("One or more changelists to delete "
3064
+ "(required unless --all is used)"))
3065
+ delete_parser.add_argument('--all', action='store_true',
3066
+ help='Delete all changelists')
3067
+ delete_parser.set_defaults(func=cl_delete)
3068
+
3069
+ # STATUS
3070
+ status_parser = subparsers.add_parser('status', aliases=['s', 'st'],
3071
+ help=("Show file changes grouped by "
3072
+ "changelist"),
3073
+ description=(
3074
+ "Show file changes grouped by "
3075
+ "changelist. Unassigned files "
3076
+ "appear under 'No Changelist'."))
3077
+ status_parser.add_argument('names', metavar='CHANGELIST', nargs='*',
3078
+ help=("Optional list of changelists to show "
3079
+ "(default: all)"))
3080
+ status_parser.add_argument('--include-no-cl', action='store_true',
3081
+ help=("When filtering by changelist names, "
3082
+ "also show files not assigned to any "
3083
+ "changelist"))
3084
+ status_parser.add_argument('--all', action='store_true',
3085
+ help=("Include files with uncommon Git "
3086
+ "status codes"))
3087
+ status_parser.add_argument('--no-color', action='store_true',
3088
+ help='Disable colored output')
3089
+ status_parser.set_defaults(func=cl_status)
3090
+
3091
+ # DIFF
3092
+ diff_parser = subparsers.add_parser('diff',
3093
+ help=("Show git diff for one or more "
3094
+ "changelists"),
3095
+ description=(
3096
+ "Show unified diff for the files "
3097
+ "in one or more changelists. "
3098
+ "By default, shows unstaged "
3099
+ "changes (like `git diff`). "
3100
+ "Use --staged for staged changes, "
3101
+ "or --both to show both."))
3102
+ diff_parser.add_argument('names', metavar='CHANGELIST', nargs='+',
3103
+ help='One or more changelists to diff')
3104
+ diff_parser.add_argument('--staged', action='store_true',
3105
+ help='Show staged changes (index vs HEAD)')
3106
+ diff_parser.add_argument('--both', action='store_true',
3107
+ help='Show both unstaged and staged diffs')
3108
+ diff_parser.set_defaults(func=cl_diff)
3109
+
3110
+ # CHECKOUT
3111
+ checkout_parser = subparsers.add_parser('checkout', aliases=['co'],
3112
+ help=("Checkout (revert) files "
3113
+ "from changelists"),
3114
+ description=(
3115
+ "Revert files in one or more "
3116
+ "changelists to their HEAD "
3117
+ "state, discarding both "
3118
+ "staged and unstaged changes "
3119
+ "in those files. This "
3120
+ "completely restores files "
3121
+ "to their last committed "
3122
+ "state. By default, asks for "
3123
+ " confirmation before "
3124
+ "proceeding."))
3125
+ checkout_parser.add_argument('names', metavar='CHANGELIST', nargs='+',
3126
+ help='One or more changelists to checkout')
3127
+ checkout_parser.add_argument('--delete', action='store_true',
3128
+ help='Delete the changelist after checkout')
3129
+ checkout_parser.add_argument('--force', '-f', action='store_true',
3130
+ help='Skip confirmation prompt')
3131
+ checkout_parser.set_defaults(func=cl_checkout)
3132
+
3133
+ # STAGE
3134
+ stage_parser = subparsers.add_parser('stage',
3135
+ help=("Stage tracked files from a "
3136
+ "changelist"),
3137
+ description=(
3138
+ "Stage all tracked files from "
3139
+ "the specified changelist. Only "
3140
+ "files already tracked by Git "
3141
+ "will be staged. Untracked files "
3142
+ "in the changelist are safely "
3143
+ "ignored and remain untracked. "
3144
+ "By default, the changelist is "
3145
+ "kept unless --delete is used."))
3146
+ stage_parser.add_argument('name', metavar='CHANGELIST',
3147
+ help='Name of the changelist to stage')
3148
+ stage_parser.add_argument('--delete', action='store_true',
3149
+ help='Delete the changelist after staging')
3150
+ stage_parser.set_defaults(func=cl_stage)
3151
+
3152
+ # UNSTAGE
3153
+ unstage_parser = subparsers.add_parser('unstage',
3154
+ help=("Unstage tracked files from "
3155
+ "a changelist"),
3156
+ description=(
3157
+ "Unstage all staged files from "
3158
+ "the specified changelist, "
3159
+ "moving them back to unstaged "
3160
+ "state in the working "
3161
+ "directory. By default, the "
3162
+ "changelist is kept unless "
3163
+ "--delete is used."))
3164
+ unstage_parser.add_argument('name', metavar='CHANGELIST',
3165
+ help='Name of the changelist to unstage')
3166
+ unstage_parser.add_argument('--delete', action='store_true',
3167
+ help='Delete the changelist after unstaging')
3168
+ unstage_parser.set_defaults(func=cl_unstage)
3169
+
3170
+ # COMMIT
3171
+ commit_parser = subparsers.add_parser('commit', aliases=['ci'],
3172
+ help=("Commit tracked files from a "
3173
+ "changelist (auto-stages, "
3174
+ "ignores untracked)"),
3175
+ description=(
3176
+ "Stage and commit all tracked "
3177
+ "files from the specified "
3178
+ "changelist using the provided "
3179
+ "message. Untracked files in "
3180
+ "the changelist are ignored and "
3181
+ "remain untracked. By default, "
3182
+ "the changelist is deleted "
3183
+ "after committing unless --keep "
3184
+ "is used."))
3185
+ commit_parser.add_argument('name', metavar='CHANGELIST',
3186
+ help='Name of the changelist to commit')
3187
+ msg_group = commit_parser.add_mutually_exclusive_group(required=True)
3188
+ msg_group.add_argument('-m', '--message',
3189
+ help='Commit message')
3190
+ msg_group.add_argument('-F', '--file',
3191
+ help='Read commit message from file')
3192
+ commit_parser.add_argument('--keep', action='store_true',
3193
+ help='Keep the changelist after committing')
3194
+ commit_parser.set_defaults(func=cl_commit)
3195
+
3196
+ # STASH
3197
+ stash_parser = subparsers.add_parser('stash', aliases=['sh'],
3198
+ help='Stash changes in changelist(s)',
3199
+ description=(
3200
+ "Stash all unstaged changes for "
3201
+ "files in the specified "
3202
+ "changelist. Only files with "
3203
+ "unstaged changes can be "
3204
+ "stashed. Use --all to stash "
3205
+ "all active changelists at once "
3206
+ "for multi-feature branch "
3207
+ "workflow."))
3208
+ stash_group = stash_parser.add_mutually_exclusive_group(required=True)
3209
+ stash_group.add_argument('name', nargs='?', metavar='CHANGELIST',
3210
+ help='Name of the changelist to stash')
3211
+ stash_group.add_argument('--all', action='store_true',
3212
+ help='Stash all active changelists')
3213
+ stash_parser.set_defaults(func=cl_stash)
3214
+
3215
+ # UNSTASH
3216
+ unstash_parser = subparsers.add_parser('unstash', aliases=['us'],
3217
+ help=("Restore stashed "
3218
+ "changelist(s)"),
3219
+ description=(
3220
+ "Restore a previously stashed "
3221
+ "changelist to the working "
3222
+ "directory. Enforces clean "
3223
+ "branch workflow by default "
3224
+ "(use --force to override). "
3225
+ "Use --all to unstash all "
3226
+ "stashed changelists to the "
3227
+ "current branch."))
3228
+ unstash_group = unstash_parser.add_mutually_exclusive_group(required=True)
3229
+ unstash_group.add_argument('name', nargs='?', metavar='CHANGELIST',
3230
+ help=('Name of the changelist to unstash '
3231
+ '(with or without _stashed suffix)'))
3232
+ unstash_group.add_argument('--all', action='store_true',
3233
+ help='Unstash all stashed changelists')
3234
+ unstash_parser.add_argument('--force', action='store_true',
3235
+ help=("Force unstash even if branch/"
3236
+ "conflicts detected"))
3237
+ unstash_parser.set_defaults(func=cl_unstash)
3238
+
3239
+ # BRANCH
3240
+ branch_parser = subparsers.add_parser('branch', aliases=['br'],
3241
+ help=("Create branch from "
3242
+ "changelist workflow"),
3243
+ description=(
3244
+ "Automate the changelist-to-"
3245
+ "branch workflow: stash all "
3246
+ "active changelists, create a "
3247
+ "new branch, and unstash the "
3248
+ "specified changelist to the "
3249
+ "new branch. This provides a "
3250
+ "clean way to move changelist "
3251
+ "work to a dedicated "
3252
+ "feature branch."))
3253
+ branch_parser.add_argument('changelist_name', metavar='CHANGELIST',
3254
+ help=("Name of the changelist to "
3255
+ "create branch for"))
3256
+ branch_parser.add_argument('branch_name',
3257
+ metavar='BRANCH', nargs='?',
3258
+ help=("Name of the new branch "
3259
+ "(defaults to changelist name)"))
3260
+ branch_parser.add_argument('--from', dest='from_branch',
3261
+ metavar='BASE_BRANCH',
3262
+ help=("Create branch from specified base "
3263
+ "branch (default: current branch)"))
3264
+ branch_parser.set_defaults(func=cl_branch)
3265
+
3266
+ # HELP
3267
+ help_parser = subparsers.add_parser('help',
3268
+ help='Show this help message',
3269
+ description=(
3270
+ "Displays a summary of "
3271
+ "available git-cl commands."))
3272
+ help_parser.set_defaults(func=cl_help, parser=parser)
3273
+
3274
+ args = parser.parse_args()
3275
+
3276
+ # If no command was provided, show help
3277
+ if args.command is None:
3278
+ parser.print_help()
3279
+ return
3280
+
3281
+ # Provide parser to all commands in case needed
3282
+ if not hasattr(args, 'parser'):
3283
+ args.parser = parser
3284
+
3285
+ args.func(args)
3286
+
3287
+
3288
+ if __name__ == '__main__':
3289
+ main()