git-p4son 0.2.10__tar.gz → 0.2.11__tar.gz
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.
- {git_p4son-0.2.10 → git_p4son-0.2.11}/PKG-INFO +14 -5
- {git_p4son-0.2.10 → git_p4son-0.2.11}/README.md +13 -4
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/__init__.py +1 -1
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/alias.py +54 -21
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/changelist_store.py +36 -6
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/cli.py +11 -7
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/common.py +70 -41
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/complete.py +5 -0
- git_p4son-0.2.11/git_p4son/config.py +89 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/git.py +32 -6
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/hooks.py +14 -5
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/init.py +16 -2
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/lib.py +67 -17
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/log.py +48 -4
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/new.py +4 -2
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/perforce.py +54 -13
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/review.py +6 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/sync.py +34 -81
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/PKG-INFO +14 -5
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/SOURCES.txt +2 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/pyproject.toml +1 -1
- git_p4son-0.2.11/tests/test_alias.py +106 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_changelist_store.py +59 -1
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_cli.py +8 -1
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_common.py +101 -6
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_complete.py +40 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_config.py +47 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_hooks.py +46 -3
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_init.py +42 -5
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_lib_changelist.py +82 -21
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_lib_edit.py +81 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_lib_review.py +27 -11
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_list_changes.py +2 -1
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_log.py +32 -0
- git_p4son-0.2.11/tests/test_new.py +56 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_perforce.py +21 -2
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_review.py +16 -1
- {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_sync.py +43 -32
- git_p4son-0.2.10/git_p4son/config.py +0 -42
- {git_p4son-0.2.10 → git_p4son-0.2.11}/LICENSE +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/__main__.py +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/completions/_git-p4son +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/completions/git-p4son.bash +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/completions/git-p4son.ps1 +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/list_changes.py +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/update.py +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/dependency_links.txt +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/entry_points.txt +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/top_level.txt +0 -0
- {git_p4son-0.2.10 → git_p4son-0.2.11}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-p4son
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.11
|
|
4
4
|
Summary: Utility for keeping a Perforce workspace and local git repo in sync
|
|
5
5
|
Author-email: Andreas Andersson <andreas@neoboid.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -317,7 +317,11 @@ git p4son new -m "New feature" --review -b main
|
|
|
317
317
|
|
|
318
318
|
### Update Command
|
|
319
319
|
|
|
320
|
-
Update an existing Perforce changelist description
|
|
320
|
+
Update an existing Perforce changelist description. Commits since the base branch replace their existing
|
|
321
|
+
entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
|
|
322
|
+
range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
|
|
323
|
+
it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
|
|
324
|
+
edit.
|
|
321
325
|
|
|
322
326
|
```sh
|
|
323
327
|
git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
|
|
@@ -464,16 +468,21 @@ git p4son alias delete myfeature
|
|
|
464
468
|
|
|
465
469
|
#### alias clean
|
|
466
470
|
|
|
467
|
-
|
|
471
|
+
Clean up changelist aliases:
|
|
468
472
|
|
|
469
473
|
```sh
|
|
470
474
|
git p4son alias clean
|
|
471
475
|
```
|
|
472
476
|
|
|
473
|
-
|
|
477
|
+
The command lists all aliases and asks how to proceed:
|
|
478
|
+
- `a` (all): Delete all aliases
|
|
479
|
+
- `i` (interactive): Review each alias one by one
|
|
480
|
+
- `q` (quit): Exit without deleting anything
|
|
481
|
+
|
|
482
|
+
In interactive mode, each alias is displayed in turn with a prompt:
|
|
474
483
|
- `y` (yes): Delete this alias
|
|
475
484
|
- `n` (no): Keep this alias
|
|
476
|
-
- `a` (all): Delete this and all remaining aliases
|
|
485
|
+
- `a` (all): Delete this and all remaining aliases (handy for keeping the first few and sweeping the rest)
|
|
477
486
|
- `q` (quit): Stop and keep remaining aliases
|
|
478
487
|
|
|
479
488
|
**Examples:**
|
|
@@ -294,7 +294,11 @@ git p4son new -m "New feature" --review -b main
|
|
|
294
294
|
|
|
295
295
|
### Update Command
|
|
296
296
|
|
|
297
|
-
Update an existing Perforce changelist description
|
|
297
|
+
Update an existing Perforce changelist description. Commits since the base branch replace their existing
|
|
298
|
+
entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
|
|
299
|
+
range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
|
|
300
|
+
it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
|
|
301
|
+
edit.
|
|
298
302
|
|
|
299
303
|
```sh
|
|
300
304
|
git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
|
|
@@ -441,16 +445,21 @@ git p4son alias delete myfeature
|
|
|
441
445
|
|
|
442
446
|
#### alias clean
|
|
443
447
|
|
|
444
|
-
|
|
448
|
+
Clean up changelist aliases:
|
|
445
449
|
|
|
446
450
|
```sh
|
|
447
451
|
git p4son alias clean
|
|
448
452
|
```
|
|
449
453
|
|
|
450
|
-
|
|
454
|
+
The command lists all aliases and asks how to proceed:
|
|
455
|
+
- `a` (all): Delete all aliases
|
|
456
|
+
- `i` (interactive): Review each alias one by one
|
|
457
|
+
- `q` (quit): Exit without deleting anything
|
|
458
|
+
|
|
459
|
+
In interactive mode, each alias is displayed in turn with a prompt:
|
|
451
460
|
- `y` (yes): Delete this alias
|
|
452
461
|
- `n` (no): Keep this alias
|
|
453
|
-
- `a` (all): Delete this and all remaining aliases
|
|
462
|
+
- `a` (all): Delete this and all remaining aliases (handy for keeping the first few and sweeping the rest)
|
|
454
463
|
- `q` (quit): Stop and keep remaining aliases
|
|
455
464
|
|
|
456
465
|
**Examples:**
|
|
@@ -54,43 +54,52 @@ def alias_delete_command(args: argparse.Namespace) -> int:
|
|
|
54
54
|
return 0
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def
|
|
58
|
-
"""Prompt until
|
|
57
|
+
def _prompt_choice(prefix: str, options: list[str]) -> str | None:
|
|
58
|
+
"""Prompt until the response matches an option or its first letter.
|
|
59
|
+
|
|
60
|
+
The choices are rendered from the options, e.g. "[y]es / [n]o", so the
|
|
61
|
+
prompt and the accepted keys cannot drift apart. Options must have
|
|
62
|
+
unique first letters. Returns the chosen option, or None on EOF.
|
|
63
|
+
"""
|
|
64
|
+
shorthands = {option[0]: option for option in options}
|
|
65
|
+
rendered = ' / '.join(f'[{option[0]}]{option[1:]}' for option in options)
|
|
66
|
+
keys = list(shorthands)
|
|
59
67
|
while True:
|
|
60
68
|
try:
|
|
61
|
-
response = input(
|
|
69
|
+
response = input(f'{prefix} {rendered}: ').strip().lower()
|
|
62
70
|
except EOFError:
|
|
63
71
|
print()
|
|
64
72
|
return None
|
|
65
73
|
|
|
66
|
-
if response in
|
|
67
|
-
return
|
|
68
|
-
elif response in
|
|
69
|
-
return
|
|
70
|
-
elif response in ('a', 'all'):
|
|
71
|
-
return 'all'
|
|
72
|
-
elif response in ('q', 'quit'):
|
|
73
|
-
return 'quit'
|
|
74
|
+
if response in shorthands:
|
|
75
|
+
return shorthands[response]
|
|
76
|
+
elif response in options:
|
|
77
|
+
return response
|
|
74
78
|
else:
|
|
75
|
-
print('Please enter
|
|
79
|
+
print('Please enter ' + ', '.join(keys[:-1]) + f' or {keys[-1]}')
|
|
76
80
|
|
|
77
81
|
|
|
78
|
-
def
|
|
79
|
-
"""
|
|
80
|
-
|
|
82
|
+
def _clean_all(aliases: list[tuple[str, str]], workspace_dir: str) -> None:
|
|
83
|
+
"""Delete every alias without further prompting."""
|
|
84
|
+
for name, _changelist in aliases:
|
|
85
|
+
if delete_changelist_alias(name, workspace_dir):
|
|
86
|
+
log.success(f'Deleted "{name}"')
|
|
81
87
|
|
|
82
|
-
aliases = list_changelist_aliases(workspace_dir)
|
|
83
|
-
if not aliases:
|
|
84
|
-
log.success('No changelist aliases to clean')
|
|
85
|
-
return 0
|
|
86
88
|
|
|
89
|
+
def _clean_interactive(aliases: list[tuple[str, str]],
|
|
90
|
+
workspace_dir: str) -> None:
|
|
91
|
+
"""Review each alias in turn with yes/no/all/quit prompts.
|
|
92
|
+
|
|
93
|
+
The all option lets the user keep the first aliases and sweep the rest:
|
|
94
|
+
answer no until the last one worth keeping, then a deletes the
|
|
95
|
+
remainder without further prompts."""
|
|
87
96
|
response = None
|
|
88
97
|
for name, changelist in aliases:
|
|
89
98
|
log.heading(f'{name} -> CL {changelist}')
|
|
90
99
|
|
|
91
100
|
if response != 'all':
|
|
92
|
-
response =
|
|
93
|
-
'Delete? [
|
|
101
|
+
response = _prompt_choice(
|
|
102
|
+
'Delete?', ['yes', 'no', 'all', 'quit'])
|
|
94
103
|
|
|
95
104
|
if response is None or response == 'quit':
|
|
96
105
|
log.info('Aborting')
|
|
@@ -102,6 +111,30 @@ def alias_clean_command(args: argparse.Namespace) -> int:
|
|
|
102
111
|
if delete_changelist_alias(name, workspace_dir):
|
|
103
112
|
log.success('Deleted')
|
|
104
113
|
|
|
114
|
+
|
|
115
|
+
def alias_clean_command(args: argparse.Namespace) -> int:
|
|
116
|
+
"""Execute the 'alias clean' command."""
|
|
117
|
+
workspace_dir = args.workspace_dir
|
|
118
|
+
|
|
119
|
+
aliases = list_changelist_aliases(workspace_dir)
|
|
120
|
+
if not aliases:
|
|
121
|
+
log.success('No changelist aliases to clean')
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
log.info(f'Found {len(aliases)}')
|
|
125
|
+
for name, changelist in aliases:
|
|
126
|
+
log.info(f'{name} -> {changelist}')
|
|
127
|
+
|
|
128
|
+
mode = _prompt_choice('Delete?', ['all', 'interactive', 'quit'])
|
|
129
|
+
if mode is None or mode == 'quit':
|
|
130
|
+
log.info('Aborting')
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
if mode == 'all':
|
|
134
|
+
_clean_all(aliases, workspace_dir)
|
|
135
|
+
else:
|
|
136
|
+
_clean_interactive(aliases, workspace_dir)
|
|
137
|
+
|
|
105
138
|
return 0
|
|
106
139
|
|
|
107
140
|
|
|
@@ -19,6 +19,13 @@ RESERVED_KEYWORDS = frozenset({'last-synced', 'branch'})
|
|
|
19
19
|
# (Windows disallows trailing dots).
|
|
20
20
|
_ALIAS_NAME_RE = re.compile(r'^[A-Za-z0-9_-]([A-Za-z0-9._-]*[A-Za-z0-9_-])?$')
|
|
21
21
|
|
|
22
|
+
# Windows device names; opening such a path hits the device namespace
|
|
23
|
+
# instead of creating a file, on any drive and regardless of extension.
|
|
24
|
+
_WINDOWS_RESERVED_NAMES = frozenset(
|
|
25
|
+
{'con', 'prn', 'aux', 'nul'}
|
|
26
|
+
| {f'com{i}' for i in range(1, 10)}
|
|
27
|
+
| {f'lpt{i}' for i in range(1, 10)})
|
|
28
|
+
|
|
22
29
|
|
|
23
30
|
def validate_alias_name(name: str) -> str | None:
|
|
24
31
|
"""Return an error message if alias name is invalid, else None."""
|
|
@@ -30,6 +37,15 @@ def validate_alias_name(name: str) -> str | None:
|
|
|
30
37
|
return (
|
|
31
38
|
f'Invalid alias name "{name}": must contain only letters, digits, '
|
|
32
39
|
'hyphens, underscores, and dots, and must not start or end with a dot')
|
|
40
|
+
if name.isdigit():
|
|
41
|
+
# Digit strings are always interpreted as changelist numbers, so
|
|
42
|
+
# such an alias could be created but never referenced.
|
|
43
|
+
return (
|
|
44
|
+
f'Invalid alias name "{name}": an all-digit name would be '
|
|
45
|
+
'indistinguishable from a changelist number')
|
|
46
|
+
if name.split('.', 1)[0].lower() in _WINDOWS_RESERVED_NAMES:
|
|
47
|
+
return (
|
|
48
|
+
f'Invalid alias name "{name}": reserved device name on Windows')
|
|
33
49
|
return None
|
|
34
50
|
|
|
35
51
|
|
|
@@ -38,6 +54,18 @@ def _changelists_dir(workspace_dir: str) -> str:
|
|
|
38
54
|
return os.path.join(workspace_dir, CONFIG_DIR, 'changelists')
|
|
39
55
|
|
|
40
56
|
|
|
57
|
+
def _alias_path(name: str, workspace_dir: str) -> str | None:
|
|
58
|
+
"""Validate name and return its store path, or None if invalid.
|
|
59
|
+
|
|
60
|
+
Validating on every lookup, not just on save, keeps raw user input
|
|
61
|
+
(e.g. "../../somefile") from escaping the store directory."""
|
|
62
|
+
error = validate_alias_name(name)
|
|
63
|
+
if error:
|
|
64
|
+
log.error(error)
|
|
65
|
+
return None
|
|
66
|
+
return os.path.join(_changelists_dir(workspace_dir), name)
|
|
67
|
+
|
|
68
|
+
|
|
41
69
|
def alias_exists(name: str, workspace_dir: str) -> bool:
|
|
42
70
|
"""Check whether a changelist alias exists."""
|
|
43
71
|
alias_path = os.path.join(_changelists_dir(workspace_dir), name)
|
|
@@ -46,13 +74,11 @@ def alias_exists(name: str, workspace_dir: str) -> bool:
|
|
|
46
74
|
|
|
47
75
|
def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force: bool = False) -> bool:
|
|
48
76
|
"""Save a changelist number under a named alias."""
|
|
49
|
-
|
|
50
|
-
if
|
|
51
|
-
log.error(error)
|
|
77
|
+
alias_path = _alias_path(name, workspace_dir)
|
|
78
|
+
if alias_path is None:
|
|
52
79
|
return False
|
|
53
80
|
|
|
54
81
|
changelists_dir = _changelists_dir(workspace_dir)
|
|
55
|
-
alias_path = os.path.join(changelists_dir, name)
|
|
56
82
|
|
|
57
83
|
if os.path.exists(alias_path) and not force:
|
|
58
84
|
log.error(
|
|
@@ -71,7 +97,9 @@ def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force:
|
|
|
71
97
|
|
|
72
98
|
def load_changelist_alias(name: str, workspace_dir: str) -> str | None:
|
|
73
99
|
"""Load a changelist number from a named alias, or None if not found."""
|
|
74
|
-
alias_path =
|
|
100
|
+
alias_path = _alias_path(name, workspace_dir)
|
|
101
|
+
if alias_path is None:
|
|
102
|
+
return None
|
|
75
103
|
|
|
76
104
|
if not os.path.exists(alias_path):
|
|
77
105
|
log.error(f'No changelist alias found: {name}')
|
|
@@ -108,7 +136,9 @@ def list_changelist_aliases(workspace_dir: str) -> list[tuple[str, str]]:
|
|
|
108
136
|
|
|
109
137
|
def delete_changelist_alias(name: str, workspace_dir: str) -> bool:
|
|
110
138
|
"""Delete a changelist alias file."""
|
|
111
|
-
alias_path =
|
|
139
|
+
alias_path = _alias_path(name, workspace_dir)
|
|
140
|
+
if alias_path is None:
|
|
141
|
+
return False
|
|
112
142
|
|
|
113
143
|
if not os.path.exists(alias_path):
|
|
114
144
|
log.error(f'No changelist alias found: {name}')
|
|
@@ -149,6 +149,7 @@ Examples:
|
|
|
149
149
|
)
|
|
150
150
|
new_parser.add_argument(
|
|
151
151
|
'-s', '--sleep',
|
|
152
|
+
type=int,
|
|
152
153
|
help='Sleep for the specified number of seconds after the command is done'
|
|
153
154
|
)
|
|
154
155
|
|
|
@@ -156,9 +157,11 @@ Examples:
|
|
|
156
157
|
update_parser = subparsers.add_parser(
|
|
157
158
|
'update',
|
|
158
159
|
help='Update an existing changelist description and open files for edit',
|
|
159
|
-
description='Update an existing Perforce changelist description
|
|
160
|
-
'
|
|
161
|
-
'
|
|
160
|
+
description='Update an existing Perforce changelist description: '
|
|
161
|
+
'commits since the base branch replace their existing entries in '
|
|
162
|
+
'the enumerated commit list and new ones are appended; entries '
|
|
163
|
+
'outside the range are kept. By default also opens changed files '
|
|
164
|
+
'for edit.'
|
|
162
165
|
)
|
|
163
166
|
update_parser.add_argument(
|
|
164
167
|
'changelist',
|
|
@@ -194,6 +197,7 @@ Examples:
|
|
|
194
197
|
)
|
|
195
198
|
update_parser.add_argument(
|
|
196
199
|
'-s', '--sleep',
|
|
200
|
+
type=int,
|
|
197
201
|
help='Sleep for the specified number of seconds after the command is done'
|
|
198
202
|
)
|
|
199
203
|
|
|
@@ -268,9 +272,9 @@ Examples:
|
|
|
268
272
|
# alias clean
|
|
269
273
|
alias_subparsers.add_parser(
|
|
270
274
|
'clean',
|
|
271
|
-
help='
|
|
272
|
-
description='
|
|
273
|
-
'yes/no/all/quit prompts'
|
|
275
|
+
help='Clean up changelist aliases',
|
|
276
|
+
description='List all changelist aliases, then delete all of them '
|
|
277
|
+
'or review each one interactively with yes/no/all/quit prompts'
|
|
274
278
|
)
|
|
275
279
|
|
|
276
280
|
# Review subcommand
|
|
@@ -468,7 +472,7 @@ def main() -> int:
|
|
|
468
472
|
exit_code = run_command(args)
|
|
469
473
|
|
|
470
474
|
if exit_code == 0 and getattr(args, 'sleep', None) is not None:
|
|
471
|
-
seconds =
|
|
475
|
+
seconds = args.sleep
|
|
472
476
|
log.heading(f'Sleeping for {seconds} seconds')
|
|
473
477
|
time.sleep(seconds)
|
|
474
478
|
log.success('awake again')
|
|
@@ -10,6 +10,7 @@ import re
|
|
|
10
10
|
import subprocess
|
|
11
11
|
import sys
|
|
12
12
|
import threading
|
|
13
|
+
import time
|
|
13
14
|
from timeit import default_timer as timer
|
|
14
15
|
from datetime import timedelta
|
|
15
16
|
from typing import IO, Callable
|
|
@@ -91,11 +92,13 @@ class RunError(CommandError):
|
|
|
91
92
|
class RunResult:
|
|
92
93
|
"""Result of a command execution."""
|
|
93
94
|
|
|
94
|
-
def __init__(self, returncode: int,
|
|
95
|
+
def __init__(self, returncode: int,
|
|
96
|
+
stdout: list[str] | bytes,
|
|
97
|
+
stderr: list[str] | bytes,
|
|
95
98
|
elapsed: timedelta | None = None) -> None:
|
|
96
99
|
self.returncode: int = returncode
|
|
97
|
-
self.stdout: list[str] = stdout
|
|
98
|
-
self.stderr: list[str] = stderr
|
|
100
|
+
self.stdout: list[str] | bytes = stdout
|
|
101
|
+
self.stderr: list[str] | bytes = stderr
|
|
99
102
|
self.elapsed: timedelta | None = elapsed
|
|
100
103
|
|
|
101
104
|
|
|
@@ -111,7 +114,10 @@ def join_command_line(command: list[str]) -> str:
|
|
|
111
114
|
|
|
112
115
|
def run(command: list[str], cwd: str = '.', dry_run: bool = False,
|
|
113
116
|
input: str | None = None,
|
|
114
|
-
env: dict[str, str] | None = None
|
|
117
|
+
env: dict[str, str] | None = None,
|
|
118
|
+
text: bool = True,
|
|
119
|
+
fail_on_returncode: bool = True
|
|
120
|
+
) -> RunResult:
|
|
115
121
|
"""
|
|
116
122
|
Run a command and return the result.
|
|
117
123
|
|
|
@@ -121,6 +127,8 @@ def run(command: list[str], cwd: str = '.', dry_run: bool = False,
|
|
|
121
127
|
dry_run: If True, only print the command without executing
|
|
122
128
|
input: Optional string to pass to the subprocess via stdin
|
|
123
129
|
env: Optional environment variables to add or override
|
|
130
|
+
text: False to get raw bytes instead of decoded lines.
|
|
131
|
+
fail_on_returncode: False to not raise on non-zero return codes.
|
|
124
132
|
|
|
125
133
|
Returns:
|
|
126
134
|
RunResult object with returncode, stdout, and stderr
|
|
@@ -131,7 +139,7 @@ def run(command: list[str], cwd: str = '.', dry_run: bool = False,
|
|
|
131
139
|
|
|
132
140
|
if dry_run:
|
|
133
141
|
log.end_command()
|
|
134
|
-
return RunResult(0, [], [])
|
|
142
|
+
return RunResult(0, [] if text else b'', [] if text else b'')
|
|
135
143
|
|
|
136
144
|
if input is not None:
|
|
137
145
|
log.end_command()
|
|
@@ -145,27 +153,45 @@ def run(command: list[str], cwd: str = '.', dry_run: bool = False,
|
|
|
145
153
|
if env:
|
|
146
154
|
command_env.update(env)
|
|
147
155
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
# Decode output as UTF-8 regardless of locale: git emits UTF-8, but
|
|
157
|
+
# Windows would otherwise decode with the ANSI code page (cp1252).
|
|
158
|
+
try:
|
|
159
|
+
result = subprocess.run(command,
|
|
160
|
+
cwd=cwd,
|
|
161
|
+
env=command_env,
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=text,
|
|
164
|
+
encoding='utf-8' if text else None,
|
|
165
|
+
errors='replace' if text else None,
|
|
166
|
+
input=input)
|
|
167
|
+
except OSError:
|
|
168
|
+
# E.g. the executable is missing; without this the spinner thread
|
|
169
|
+
# keeps rewriting the line under the error message.
|
|
170
|
+
log.stop_spinner()
|
|
171
|
+
raise
|
|
154
172
|
|
|
155
173
|
end_timestamp = timer()
|
|
156
174
|
elapsed = timedelta(seconds=end_timestamp - start_timestamp)
|
|
157
175
|
|
|
158
176
|
log.stop_spinner()
|
|
159
177
|
|
|
160
|
-
if result.returncode != 0:
|
|
178
|
+
if fail_on_returncode and result.returncode != 0:
|
|
179
|
+
if text:
|
|
180
|
+
stderr = result.stderr.splitlines()
|
|
181
|
+
else:
|
|
182
|
+
# Bytes mode: decode so the error still carries the reason.
|
|
183
|
+
stderr = result.stderr.decode('utf-8', 'replace').splitlines()
|
|
161
184
|
raise RunError(
|
|
162
185
|
join_command_line(command),
|
|
163
186
|
returncode=result.returncode,
|
|
164
|
-
stderr=
|
|
187
|
+
stderr=stderr,
|
|
165
188
|
)
|
|
166
189
|
|
|
167
|
-
|
|
168
|
-
|
|
190
|
+
if text:
|
|
191
|
+
return RunResult(result.returncode, result.stdout.splitlines(),
|
|
192
|
+
result.stderr.splitlines(), elapsed=elapsed)
|
|
193
|
+
return RunResult(result.returncode, result.stdout,
|
|
194
|
+
result.stderr, elapsed=elapsed)
|
|
169
195
|
|
|
170
196
|
|
|
171
197
|
def enqueue_lines(stream: IO[str], output_queue: queue.Queue[str]) -> None:
|
|
@@ -204,12 +230,24 @@ def run_with_output(command: list[str], cwd: str = '.',
|
|
|
204
230
|
if env:
|
|
205
231
|
command_env.update(env)
|
|
206
232
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
233
|
+
# UTF-8 for the same reason as in run(); a decode error would
|
|
234
|
+
# otherwise kill the reader threads silently.
|
|
235
|
+
try:
|
|
236
|
+
process_cm = subprocess.Popen(command,
|
|
237
|
+
cwd=cwd,
|
|
238
|
+
env=command_env,
|
|
239
|
+
stdout=subprocess.PIPE,
|
|
240
|
+
stderr=subprocess.PIPE,
|
|
241
|
+
text=True,
|
|
242
|
+
encoding='utf-8',
|
|
243
|
+
errors='replace')
|
|
244
|
+
except OSError:
|
|
245
|
+
# E.g. the executable is missing; without this the spinner thread
|
|
246
|
+
# keeps rewriting the line under the error message.
|
|
247
|
+
log.stop_spinner()
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
with process_cm as process:
|
|
213
251
|
|
|
214
252
|
output_queue: queue.Queue[str] = queue.Queue()
|
|
215
253
|
out_thread = threading.Thread(
|
|
@@ -235,33 +273,24 @@ def run_with_output(command: list[str], cwd: str = '.',
|
|
|
235
273
|
pass
|
|
236
274
|
|
|
237
275
|
try:
|
|
238
|
-
|
|
276
|
+
# The reader threads own the pipes and exit at EOF, so they are
|
|
277
|
+
# the source of truth for "no more output is coming". Looping on
|
|
278
|
+
# process.poll() instead would race: the process can exit while
|
|
279
|
+
# lines are still in the pipe buffer, and anything enqueued after
|
|
280
|
+
# the last drain would be lost.
|
|
281
|
+
while out_thread.is_alive() or err_thread.is_alive():
|
|
239
282
|
drain_queue(output_queue, stdout_lines, sys.stdout)
|
|
240
283
|
drain_queue(error_queue, stderr_lines, sys.stderr)
|
|
241
|
-
|
|
242
|
-
if output_queue.empty() and error_queue.empty():
|
|
243
|
-
break
|
|
284
|
+
time.sleep(0.05)
|
|
244
285
|
|
|
245
|
-
# Wait for threads to finish
|
|
246
286
|
out_thread.join()
|
|
247
287
|
err_thread.join()
|
|
248
288
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if final_stdout:
|
|
253
|
-
final_stdout_lines = final_stdout.splitlines()
|
|
254
|
-
stdout_lines = stdout_lines + final_stdout_lines
|
|
255
|
-
for line in final_stdout_lines:
|
|
256
|
-
if on_output:
|
|
257
|
-
on_output(line=line, stream=sys.stdout)
|
|
289
|
+
# Final drain for lines enqueued after the last pass above.
|
|
290
|
+
drain_queue(output_queue, stdout_lines, sys.stdout)
|
|
291
|
+
drain_queue(error_queue, stderr_lines, sys.stderr)
|
|
258
292
|
|
|
259
|
-
|
|
260
|
-
final_stderr_lines = final_stderr.splitlines()
|
|
261
|
-
stderr_lines = stderr_lines + final_stderr_lines
|
|
262
|
-
for line in final_stderr_lines:
|
|
263
|
-
if on_output:
|
|
264
|
-
on_output(line=line, stream=sys.stderr)
|
|
293
|
+
returncode = process.wait()
|
|
265
294
|
|
|
266
295
|
except KeyboardInterrupt:
|
|
267
296
|
log.stop_spinner()
|
|
@@ -11,6 +11,7 @@ import argparse
|
|
|
11
11
|
from .changelist_store import list_changelist_aliases
|
|
12
12
|
from .common import branch_to_alias
|
|
13
13
|
from .git import get_current_branch, get_workspace_dir
|
|
14
|
+
from .log import log
|
|
14
15
|
|
|
15
16
|
_HIDDEN_COMMANDS = frozenset({'complete', 'completion', '_sequence-editor'})
|
|
16
17
|
|
|
@@ -220,6 +221,10 @@ def _complete(parser, words, workspace_dir=None):
|
|
|
220
221
|
def run_complete(words):
|
|
221
222
|
"""Execute the complete command with the given word list."""
|
|
222
223
|
from .cli import create_parser
|
|
224
|
+
# stdout is reserved for completion candidates: any status line or
|
|
225
|
+
# command echo from helpers (e.g. get_current_branch running git)
|
|
226
|
+
# would be offered to the user as a candidate by the shell.
|
|
227
|
+
log.quiet_mode = True
|
|
223
228
|
parser = create_parser()
|
|
224
229
|
workspace_dir = get_workspace_dir()
|
|
225
230
|
candidates = _complete(parser, words, workspace_dir)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for git-p4son.
|
|
3
|
+
|
|
4
|
+
Reads and writes per-repo config stored in .git-p4son/config.toml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tomllib
|
|
10
|
+
|
|
11
|
+
from . import CONFIG_DIR
|
|
12
|
+
|
|
13
|
+
_BARE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_path(workspace_dir: str) -> str:
|
|
17
|
+
"""Return the path to the config file."""
|
|
18
|
+
return os.path.join(workspace_dir, CONFIG_DIR, 'config.toml')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config(workspace_dir: str) -> dict:
|
|
22
|
+
"""Read and parse the config file. Returns empty dict if missing."""
|
|
23
|
+
path = config_path(workspace_dir)
|
|
24
|
+
if not os.path.exists(path):
|
|
25
|
+
return {}
|
|
26
|
+
with open(path, 'rb') as f:
|
|
27
|
+
return tomllib.load(f)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_config(workspace_dir: str, config: dict) -> None:
|
|
31
|
+
"""Merge config into the config file, section by section.
|
|
32
|
+
|
|
33
|
+
Sections not named in config are preserved (e.g. a configured [hooks]
|
|
34
|
+
section survives re-running init); keys within a named section are
|
|
35
|
+
updated rather than replacing the section wholesale."""
|
|
36
|
+
merged = load_config(workspace_dir)
|
|
37
|
+
for section, values in config.items():
|
|
38
|
+
merged.setdefault(section, {}).update(values)
|
|
39
|
+
|
|
40
|
+
path = config_path(workspace_dir)
|
|
41
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
42
|
+
# tomllib reads strict UTF-8, so write UTF-8 explicitly.
|
|
43
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
44
|
+
for section, values in merged.items():
|
|
45
|
+
_write_table(f, _format_key(section), values)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_string(value: str) -> str:
|
|
49
|
+
escaped = value.replace('\\', '\\\\').replace('"', '\\"')
|
|
50
|
+
return f'"{escaped}"'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_key(key: str) -> str:
|
|
54
|
+
"""Format a TOML key, quoting it when it is not a bare key."""
|
|
55
|
+
if _BARE_KEY_RE.match(key):
|
|
56
|
+
return key
|
|
57
|
+
return _format_string(key)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_value(value) -> str:
|
|
61
|
+
"""Format a TOML value: strings, booleans, numbers, and lists."""
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
return _format_string(value)
|
|
64
|
+
if isinstance(value, bool):
|
|
65
|
+
return 'true' if value else 'false'
|
|
66
|
+
if isinstance(value, (int, float)):
|
|
67
|
+
return str(value)
|
|
68
|
+
if isinstance(value, list):
|
|
69
|
+
return '[' + ', '.join(_format_value(item) for item in value) + ']'
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f'Unsupported config value type: {type(value).__name__}')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_table(f, name: str, table: dict) -> None:
|
|
75
|
+
"""Write a [name] table; nested dicts become [name.sub] tables."""
|
|
76
|
+
scalars = {k: v for k, v in table.items() if not isinstance(v, dict)}
|
|
77
|
+
subtables = {k: v for k, v in table.items() if isinstance(v, dict)}
|
|
78
|
+
f.write(f'[{name}]\n')
|
|
79
|
+
for key, value in scalars.items():
|
|
80
|
+
f.write(f'{_format_key(key)} = {_format_value(value)}\n')
|
|
81
|
+
f.write('\n')
|
|
82
|
+
for key, subtable in subtables.items():
|
|
83
|
+
_write_table(f, f'{name}.{_format_key(key)}', subtable)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_depot_root(workspace_dir: str) -> str | None:
|
|
87
|
+
"""Get the depot root from config, or None if not configured."""
|
|
88
|
+
config = load_config(workspace_dir)
|
|
89
|
+
return config.get('depot', {}).get('root')
|