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.
Files changed (50) hide show
  1. {git_p4son-0.2.10 → git_p4son-0.2.11}/PKG-INFO +14 -5
  2. {git_p4son-0.2.10 → git_p4son-0.2.11}/README.md +13 -4
  3. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/alias.py +54 -21
  5. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/changelist_store.py +36 -6
  6. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/cli.py +11 -7
  7. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/common.py +70 -41
  8. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/complete.py +5 -0
  9. git_p4son-0.2.11/git_p4son/config.py +89 -0
  10. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/git.py +32 -6
  11. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/hooks.py +14 -5
  12. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/init.py +16 -2
  13. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/lib.py +67 -17
  14. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/log.py +48 -4
  15. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/new.py +4 -2
  16. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/perforce.py +54 -13
  17. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/review.py +6 -0
  18. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/sync.py +34 -81
  19. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/PKG-INFO +14 -5
  20. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/SOURCES.txt +2 -0
  21. {git_p4son-0.2.10 → git_p4son-0.2.11}/pyproject.toml +1 -1
  22. git_p4son-0.2.11/tests/test_alias.py +106 -0
  23. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_changelist_store.py +59 -1
  24. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_cli.py +8 -1
  25. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_common.py +101 -6
  26. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_complete.py +40 -0
  27. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_config.py +47 -0
  28. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_hooks.py +46 -3
  29. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_init.py +42 -5
  30. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_lib_changelist.py +82 -21
  31. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_lib_edit.py +81 -0
  32. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_lib_review.py +27 -11
  33. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_list_changes.py +2 -1
  34. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_log.py +32 -0
  35. git_p4son-0.2.11/tests/test_new.py +56 -0
  36. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_perforce.py +21 -2
  37. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_review.py +16 -1
  38. {git_p4son-0.2.10 → git_p4son-0.2.11}/tests/test_sync.py +43 -32
  39. git_p4son-0.2.10/git_p4son/config.py +0 -42
  40. {git_p4son-0.2.10 → git_p4son-0.2.11}/LICENSE +0 -0
  41. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/__main__.py +0 -0
  42. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/completions/_git-p4son +0 -0
  43. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/completions/git-p4son.bash +0 -0
  44. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/completions/git-p4son.ps1 +0 -0
  45. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/list_changes.py +0 -0
  46. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son/update.py +0 -0
  47. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/dependency_links.txt +0 -0
  48. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/entry_points.txt +0 -0
  49. {git_p4son-0.2.10 → git_p4son-0.2.11}/git_p4son.egg-info/top_level.txt +0 -0
  50. {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.10
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 by replacing the enumerated commit list with the current commits since the base branch. By default also opens changed files for edit.
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
- Interactively review and delete changelist aliases:
471
+ Clean up changelist aliases:
468
472
 
469
473
  ```sh
470
474
  git p4son alias clean
471
475
  ```
472
476
 
473
- This command iterates through each alias, displays it, and prompts for action:
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 by replacing the enumerated commit list with the current commits since the base branch. By default also opens changed files for edit.
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
- Interactively review and delete changelist aliases:
448
+ Clean up changelist aliases:
445
449
 
446
450
  ```sh
447
451
  git p4son alias clean
448
452
  ```
449
453
 
450
- This command iterates through each alias, displays it, and prompts for action:
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:**
@@ -7,6 +7,6 @@ creating and updating changelists, and managing Swarm reviews.
7
7
 
8
8
  CONFIG_DIR = '.git-p4son'
9
9
 
10
- __version__ = "0.2.10"
10
+ __version__ = "0.2.11"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -54,43 +54,52 @@ def alias_delete_command(args: argparse.Namespace) -> int:
54
54
  return 0
55
55
 
56
56
 
57
- def _prompt_delete(prompt: str) -> str | None:
58
- """Prompt until a valid y/n/a/q response is given. Returns None on EOF."""
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(prompt).strip().lower()
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 ('y', 'yes'):
67
- return 'yes'
68
- elif response in ('n', 'no'):
69
- return 'no'
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 y, n, a, or q')
79
+ print('Please enter ' + ', '.join(keys[:-1]) + f' or {keys[-1]}')
76
80
 
77
81
 
78
- def alias_clean_command(args: argparse.Namespace) -> int:
79
- """Execute the 'alias clean' command with interactive prompts."""
80
- workspace_dir = args.workspace_dir
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 = _prompt_delete(
93
- 'Delete? [y]es / [n]o / [a]ll / [q]uit: ')
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
- error = validate_alias_name(name)
50
- if error:
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 = os.path.join(_changelists_dir(workspace_dir), name)
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 = os.path.join(_changelists_dir(workspace_dir), name)
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 by '
160
- 'replacing the enumerated commit list with the current commits '
161
- 'since the base branch. By default also opens changed files for edit.'
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='Interactive cleanup of changelist aliases',
272
- description='Interactively review and delete changelist aliases with '
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 = int(args.sleep)
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, stdout: list[str], stderr: list[str],
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) -> RunResult:
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
- result = subprocess.run(command,
149
- cwd=cwd,
150
- env=command_env,
151
- capture_output=True,
152
- text=True,
153
- input=input)
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=result.stderr.splitlines(),
187
+ stderr=stderr,
165
188
  )
166
189
 
167
- return RunResult(result.returncode, result.stdout.splitlines(),
168
- result.stderr.splitlines(), elapsed=elapsed)
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
- with subprocess.Popen(command,
208
- cwd=cwd,
209
- env=command_env,
210
- stdout=subprocess.PIPE,
211
- stderr=subprocess.PIPE,
212
- text=True) as process:
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
- while True:
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
- if process.poll() is not None:
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
- (final_stdout, final_stderr) = process.communicate()
250
- returncode = process.returncode
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
- if final_stderr:
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')