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()
|