git-p4son 0.2.7__tar.gz → 0.2.9__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 (45) hide show
  1. {git_p4son-0.2.7 → git_p4son-0.2.9}/PKG-INFO +1 -1
  2. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/__init__.py +1 -1
  3. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/changelist_store.py +23 -2
  4. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/common.py +16 -0
  5. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/new.py +10 -2
  6. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/perforce.py +12 -18
  7. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/review.py +9 -1
  8. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/sync.py +3 -17
  9. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son.egg-info/PKG-INFO +1 -1
  10. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son.egg-info/SOURCES.txt +1 -0
  11. {git_p4son-0.2.7 → git_p4son-0.2.9}/pyproject.toml +1 -1
  12. git_p4son-0.2.9/tests/test_changelist_store.py +76 -0
  13. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_common.py +33 -14
  14. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_lib_edit.py +22 -0
  15. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_sync.py +4 -36
  16. {git_p4son-0.2.7 → git_p4son-0.2.9}/LICENSE +0 -0
  17. {git_p4son-0.2.7 → git_p4son-0.2.9}/README.md +0 -0
  18. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/__main__.py +0 -0
  19. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/alias.py +0 -0
  20. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/cli.py +0 -0
  21. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/complete.py +0 -0
  22. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/completions/_git-p4son +0 -0
  23. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/completions/git-p4son.bash +0 -0
  24. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/completions/git-p4son.ps1 +0 -0
  25. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/config.py +0 -0
  26. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/git.py +0 -0
  27. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/init.py +0 -0
  28. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/lib.py +0 -0
  29. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/list_changes.py +0 -0
  30. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/log.py +0 -0
  31. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son/update.py +0 -0
  32. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son.egg-info/dependency_links.txt +0 -0
  33. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son.egg-info/entry_points.txt +0 -0
  34. {git_p4son-0.2.7 → git_p4son-0.2.9}/git_p4son.egg-info/top_level.txt +0 -0
  35. {git_p4son-0.2.7 → git_p4son-0.2.9}/setup.cfg +0 -0
  36. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_cli.py +0 -0
  37. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_complete.py +0 -0
  38. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_config.py +0 -0
  39. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_init.py +0 -0
  40. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_lib_changelist.py +0 -0
  41. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_lib_review.py +0 -0
  42. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_list_changes.py +0 -0
  43. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_log.py +0 -0
  44. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_perforce.py +0 -0
  45. {git_p4son-0.2.7 → git_p4son-0.2.9}/tests/test_review.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.2.7
3
+ Version: 0.2.9
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
@@ -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.7"
10
+ __version__ = "0.2.9"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -5,6 +5,7 @@ Stores named aliases for changelist numbers in .git-p4son/changelists/<name>.
5
5
  """
6
6
 
7
7
  import os
8
+ import re
8
9
 
9
10
  from . import CONFIG_DIR
10
11
  from .log import log
@@ -12,6 +13,25 @@ from .log import log
12
13
 
13
14
  RESERVED_KEYWORDS = frozenset({'last-synced', 'branch'})
14
15
 
16
+ # Allowed characters: ASCII letters, digits, hyphen, underscore, dot.
17
+ # Must not start or end with a dot, so "." and ".." are rejected and the
18
+ # filename does not collide with hidden files or platform-specific quirks
19
+ # (Windows disallows trailing dots).
20
+ _ALIAS_NAME_RE = re.compile(r'^[A-Za-z0-9_-]([A-Za-z0-9._-]*[A-Za-z0-9_-])?$')
21
+
22
+
23
+ def validate_alias_name(name: str) -> str | None:
24
+ """Return an error message if alias name is invalid, else None."""
25
+ if not name:
26
+ return 'Alias name cannot be empty'
27
+ if name in RESERVED_KEYWORDS:
28
+ return f'Alias name "{name}" is a reserved keyword'
29
+ if not _ALIAS_NAME_RE.match(name):
30
+ return (
31
+ f'Invalid alias name "{name}": must contain only letters, digits, '
32
+ 'hyphens, underscores, and dots, and must not start or end with a dot')
33
+ return None
34
+
15
35
 
16
36
  def _changelists_dir(workspace_dir: str) -> str:
17
37
  """Return the path to the changelists alias directory."""
@@ -26,8 +46,9 @@ def alias_exists(name: str, workspace_dir: str) -> bool:
26
46
 
27
47
  def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force: bool = False) -> bool:
28
48
  """Save a changelist number under a named alias."""
29
- if name in RESERVED_KEYWORDS:
30
- log.error(f'Alias name "{name}" is a reserved keyword')
49
+ error = validate_alias_name(name)
50
+ if error:
51
+ log.error(error)
31
52
  return False
32
53
 
33
54
  changelists_dir = _changelists_dir(workspace_dir)
@@ -2,6 +2,7 @@
2
2
  Common utilities shared between sync and edit commands.
3
3
  """
4
4
 
5
+ import os
5
6
  import queue
6
7
  import subprocess
7
8
  import sys
@@ -13,6 +14,19 @@ from typing import IO, Callable
13
14
  from .log import log
14
15
 
15
16
 
17
+ def _env_with_pwd(cwd: str) -> dict[str, str]:
18
+ """Return a copy of os.environ with PWD set to abspath(cwd).
19
+
20
+ subprocess only changes the child's kernel cwd; PWD is inherited from
21
+ the parent. Tools that read PWD for relative-path resolution (notably
22
+ 'p4 add') would otherwise resolve paths against the wrong directory
23
+ when git-p4son is invoked from a subdirectory of the workspace.
24
+ """
25
+ env = os.environ.copy()
26
+ env['PWD'] = os.path.abspath(cwd)
27
+ return env
28
+
29
+
16
30
  def branch_to_alias(branch_name: str) -> str:
17
31
  """Sanitize a branch name for use as an alias filename."""
18
32
  return branch_name.replace('/', '-')
@@ -85,6 +99,7 @@ def run(command: list[str], cwd: str = '.', dry_run: bool = False,
85
99
 
86
100
  result = subprocess.run(command,
87
101
  cwd=cwd,
102
+ env=_env_with_pwd(cwd),
88
103
  capture_output=True,
89
104
  text=True,
90
105
  input=input)
@@ -136,6 +151,7 @@ def run_with_output(command: list[str], cwd: str = '.', on_output: Callable[...,
136
151
 
137
152
  with subprocess.Popen(command,
138
153
  cwd=cwd,
154
+ env=_env_with_pwd(cwd),
139
155
  stdout=subprocess.PIPE,
140
156
  stderr=subprocess.PIPE,
141
157
  text=True) as process:
@@ -6,7 +6,11 @@ creates a Swarm review.
6
6
  """
7
7
 
8
8
  import argparse
9
- from .changelist_store import alias_exists, save_changelist_alias
9
+ from .changelist_store import (
10
+ alias_exists,
11
+ save_changelist_alias,
12
+ validate_alias_name,
13
+ )
10
14
  from .lib import create_changelist, open_changes_for_edit
11
15
  from .perforce import add_review_keyword_to_changelist, p4_shelve_changelist
12
16
  from .log import log
@@ -16,8 +20,12 @@ def new_command(args: argparse.Namespace) -> int:
16
20
  """Execute the new command."""
17
21
  workspace_dir = args.workspace_dir
18
22
 
19
- # Check alias availability before creating the changelist
23
+ # Validate alias name and availability before creating the changelist
20
24
  if args.alias and not args.dry_run:
25
+ error = validate_alias_name(args.alias)
26
+ if error:
27
+ log.error(error)
28
+ return 1
21
29
  if alias_exists(args.alias, workspace_dir) and not args.force:
22
30
  log.error(
23
31
  f'Alias "{args.alias}" already exists '
@@ -207,6 +207,15 @@ def _ensure_in_changelist(filename: str, p4_action: str, changelist: str,
207
207
  # Action mismatch - revert first, then reopen with correct action.
208
208
  # p4 revert overwrites the file on disk with the depot version,
209
209
  # so we need git restore afterwards to get the git content back.
210
+
211
+ # add -> edit: the file is new to the depot, so it must stay as add.
212
+ # This happens when a file is added in one commit and modified in the next.
213
+ if current_action == 'add' and p4_action == 'edit':
214
+ if current_cl != changelist:
215
+ run(['p4', 'reopen', '-c', changelist, filename],
216
+ cwd=workspace_dir, dry_run=dry_run)
217
+ return
218
+
210
219
  run(['p4', 'revert', filename], cwd=workspace_dir, dry_run=dry_run)
211
220
  # For add -> delete: the file never existed in the depot, so just revert.
212
221
  if current_action == 'add' and p4_action == 'delete':
@@ -297,9 +306,8 @@ def parse_p4_sync_line(line: str) -> tuple[str | None, str | None]:
297
306
  class P4SyncOutputProcessor:
298
307
  """Process p4 sync output in real-time."""
299
308
 
300
- def __init__(self, file_count_to_sync: int) -> None:
309
+ def __init__(self) -> None:
301
310
  self.synced_file_count: int = 0
302
- self.file_count_to_sync: int = file_count_to_sync
303
311
  self.stats: dict[str, int] = {
304
312
  mode: 0 for mode in ['add', 'del', 'upd', 'clb']}
305
313
 
@@ -310,18 +318,12 @@ class P4SyncOutputProcessor:
310
318
 
311
319
  mode, filename = parse_p4_sync_line(line)
312
320
  if not mode or not filename:
313
- log.verbose(f'Unparsable line: {line}')
321
+ log.warning(f'Unparsable line: {line}')
314
322
  return
315
323
 
316
324
  self.stats[mode] += 1
317
325
  self.synced_file_count += 1
318
326
 
319
- if self.file_count_to_sync >= 0:
320
- log.verbose(
321
- f'{mode}: {filename} ({self.synced_file_count}/{self.file_count_to_sync})')
322
- else:
323
- log.verbose(f'{mode}: {filename}')
324
-
325
327
  def get_summary(self) -> str:
326
328
  """Get a one-line sync summary."""
327
329
  synced_count = self.stats['add'] + \
@@ -343,18 +345,10 @@ class P4SyncOutputProcessor:
343
345
 
344
346
  def p4_force_sync_file(changelist: int, filename: str, workspace_dir: str) -> None:
345
347
  """Force sync a single file."""
346
- output_processor = P4SyncOutputProcessor(-1)
348
+ output_processor = P4SyncOutputProcessor()
347
349
  result = run_with_output(
348
350
  ['p4', 'sync', '-f', f'{filename}@{changelist}'],
349
351
  cwd=workspace_dir, on_output=output_processor)
350
352
  log.info(output_processor.get_summary())
351
353
  if result.elapsed:
352
354
  log.elapsed(result.elapsed)
353
-
354
-
355
- def get_file_count_to_sync(changelist: int, depot_root: str,
356
- workspace_dir: str) -> int:
357
- """Get the number of files that need to be synced."""
358
- res = run(['p4', 'sync', '-n', f'{depot_root}/...@{changelist}'],
359
- cwd=workspace_dir)
360
- return len(res.stdout)
@@ -10,7 +10,7 @@ import os
10
10
  import shlex
11
11
  import subprocess
12
12
  from . import CONFIG_DIR
13
- from .changelist_store import alias_exists
13
+ from .changelist_store import alias_exists, validate_alias_name
14
14
  from .git import get_commit_lines_since, resolve_editor
15
15
  from .log import log
16
16
 
@@ -58,6 +58,14 @@ def review_command(args: argparse.Namespace) -> int:
58
58
  """Execute the review command."""
59
59
  workspace_dir = args.workspace_dir
60
60
 
61
+ # Validate alias name before starting
62
+ log.heading(f'Validating alias "{args.alias}"')
63
+ error = validate_alias_name(args.alias)
64
+ if error:
65
+ log.error(error)
66
+ return 1
67
+ log.success('Done')
68
+
61
69
  # Check alias availability before starting
62
70
  if not args.force:
63
71
  log.heading(f'Checking alias "{args.alias}" is available')
@@ -11,7 +11,6 @@ from .config import get_depot_root
11
11
  from .git import add_all_files, commit, get_dirty_files
12
12
  from .log import log
13
13
  from .perforce import (
14
- get_file_count_to_sync,
15
14
  get_latest_changelist,
16
15
  p4_force_sync_file,
17
16
  p4_get_opened_files,
@@ -62,9 +61,8 @@ def parse_p4_sync_line(line: str) -> tuple[str | None, str | None]:
62
61
  class P4SyncOutputProcessor:
63
62
  """Process p4 sync output in real-time."""
64
63
 
65
- def __init__(self, file_count_to_sync: int) -> None:
64
+ def __init__(self) -> None:
66
65
  self.synced_file_count: int = 0
67
- self.file_count_to_sync: int = file_count_to_sync
68
66
  self.stats: dict[str, int] = {
69
67
  mode: 0 for mode in ['add', 'del', 'upd', 'clb']}
70
68
 
@@ -75,18 +73,12 @@ class P4SyncOutputProcessor:
75
73
 
76
74
  mode, filename = parse_p4_sync_line(line)
77
75
  if not mode or not filename:
78
- log.verbose(f'Unparsable line: {line}')
76
+ log.warning(f'Unparsable line: {line}')
79
77
  return
80
78
 
81
79
  self.stats[mode] += 1
82
80
  self.synced_file_count += 1
83
81
 
84
- if self.file_count_to_sync >= 0:
85
- log.verbose(
86
- f'{mode}: {filename} ({self.synced_file_count}/{self.file_count_to_sync})')
87
- else:
88
- log.verbose(f'{mode}: {filename}')
89
-
90
82
  def get_summary(self) -> str:
91
83
  """Get a one-line sync summary."""
92
84
  synced_count = self.stats['add'] + \
@@ -114,14 +106,8 @@ def p4_sync(changelist: int, label: str, force: bool, depot_root: str,
114
106
  Raises CommandError on actual command failures.
115
107
  """
116
108
  log.heading(f'Syncing to {label} CL ({changelist})')
117
- file_count_to_sync = get_file_count_to_sync(changelist, depot_root,
118
- workspace_dir)
119
- if file_count_to_sync == 0:
120
- log.success('All files up to date')
121
- return True
122
- log.info(f'{file_count_to_sync} files to sync')
123
109
 
124
- output_processor = P4SyncOutputProcessor(file_count_to_sync)
110
+ output_processor = P4SyncOutputProcessor()
125
111
  try:
126
112
  result = run_with_output(
127
113
  ['p4', 'sync', f'{depot_root}/...@{changelist}'],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.2.7
3
+ Version: 0.2.9
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
@@ -27,6 +27,7 @@ git_p4son.egg-info/top_level.txt
27
27
  git_p4son/completions/_git-p4son
28
28
  git_p4son/completions/git-p4son.bash
29
29
  git_p4son/completions/git-p4son.ps1
30
+ tests/test_changelist_store.py
30
31
  tests/test_cli.py
31
32
  tests/test_common.py
32
33
  tests/test_complete.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-p4son"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "Utility for keeping a Perforce workspace and local git repo in sync"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,76 @@
1
+ """Tests for git_p4son.changelist_store module."""
2
+
3
+ import unittest
4
+
5
+ from git_p4son.changelist_store import validate_alias_name
6
+
7
+
8
+ class TestValidateAliasName(unittest.TestCase):
9
+ def test_simple_name(self):
10
+ self.assertIsNone(validate_alias_name('my-feature'))
11
+
12
+ def test_alphanumeric(self):
13
+ self.assertIsNone(validate_alias_name('feat123'))
14
+
15
+ def test_underscores(self):
16
+ self.assertIsNone(validate_alias_name('my_feature'))
17
+
18
+ def test_dots_in_middle(self):
19
+ self.assertIsNone(validate_alias_name('feat.v1.0'))
20
+
21
+ def test_single_char(self):
22
+ self.assertIsNone(validate_alias_name('a'))
23
+
24
+ def test_leading_hyphen_allowed(self):
25
+ self.assertIsNone(validate_alias_name('-foo'))
26
+
27
+ def test_empty_rejected(self):
28
+ error = validate_alias_name('')
29
+ self.assertIsNotNone(error)
30
+ self.assertIn('empty', error)
31
+
32
+ def test_reserved_branch_rejected(self):
33
+ error = validate_alias_name('branch')
34
+ self.assertIsNotNone(error)
35
+ self.assertIn('reserved', error)
36
+
37
+ def test_reserved_last_synced_rejected(self):
38
+ error = validate_alias_name('last-synced')
39
+ self.assertIsNotNone(error)
40
+ self.assertIn('reserved', error)
41
+
42
+ def test_spaces_rejected(self):
43
+ error = validate_alias_name('Fix a couple of small bugs')
44
+ self.assertIsNotNone(error)
45
+ self.assertIn('Invalid alias name', error)
46
+
47
+ def test_slash_rejected(self):
48
+ error = validate_alias_name('feat/foo')
49
+ self.assertIsNotNone(error)
50
+
51
+ def test_backslash_rejected(self):
52
+ error = validate_alias_name('feat\\foo')
53
+ self.assertIsNotNone(error)
54
+
55
+ def test_leading_dot_rejected(self):
56
+ error = validate_alias_name('.hidden')
57
+ self.assertIsNotNone(error)
58
+
59
+ def test_trailing_dot_rejected(self):
60
+ error = validate_alias_name('foo.')
61
+ self.assertIsNotNone(error)
62
+
63
+ def test_dot_alone_rejected(self):
64
+ self.assertIsNotNone(validate_alias_name('.'))
65
+
66
+ def test_double_dot_rejected(self):
67
+ self.assertIsNotNone(validate_alias_name('..'))
68
+
69
+ def test_special_chars_rejected(self):
70
+ for ch in ['*', '?', ':', '"', '|', '<', '>', '\'', '(', ')', '!']:
71
+ with self.subTest(ch=ch):
72
+ self.assertIsNotNone(validate_alias_name(f'foo{ch}bar'))
73
+
74
+
75
+ if __name__ == '__main__':
76
+ unittest.main()
@@ -141,6 +141,25 @@ class TestBranchToAlias(unittest.TestCase):
141
141
  self.assertEqual(branch_to_alias('a/b/c'), 'a-b-c')
142
142
 
143
143
 
144
+ class TestRunSetsPWD(unittest.TestCase):
145
+ @mock.patch('subprocess.run')
146
+ def test_pwd_overrides_inherited_value(self, mock_subprocess_run):
147
+ mock_subprocess_run.return_value = mock.Mock(
148
+ returncode=0, stdout='', stderr='')
149
+ with mock.patch.dict(os.environ, {'PWD': '/some/other/dir'}):
150
+ run(['p4', 'add', 'foo.txt'], cwd='/workspace')
151
+ env = mock_subprocess_run.call_args.kwargs['env']
152
+ self.assertEqual(env['PWD'], '/workspace')
153
+
154
+ @mock.patch('subprocess.run')
155
+ def test_pwd_uses_absolute_path(self, mock_subprocess_run):
156
+ mock_subprocess_run.return_value = mock.Mock(
157
+ returncode=0, stdout='', stderr='')
158
+ run(['p4', 'add', 'foo.txt'], cwd='.')
159
+ env = mock_subprocess_run.call_args.kwargs['env']
160
+ self.assertEqual(env['PWD'], os.path.abspath('.'))
161
+
162
+
144
163
  class TestJoinCommandLine(unittest.TestCase):
145
164
  def test_simple_command(self):
146
165
  result = join_command_line(['git', 'status'])
@@ -210,13 +229,13 @@ class TestRun(unittest.TestCase):
210
229
  stderr='',
211
230
  )
212
231
  result = run(['git', 'status'], cwd='/tmp')
213
- mock_subprocess_run.assert_called_once_with(
214
- ['git', 'status'],
215
- cwd='/tmp',
216
- capture_output=True,
217
- text=True,
218
- input=None,
219
- )
232
+ call = mock_subprocess_run.call_args
233
+ self.assertEqual(call.args, (['git', 'status'],))
234
+ self.assertEqual(call.kwargs['cwd'], '/tmp')
235
+ self.assertEqual(call.kwargs['capture_output'], True)
236
+ self.assertEqual(call.kwargs['text'], True)
237
+ self.assertEqual(call.kwargs['input'], None)
238
+ self.assertEqual(call.kwargs['env']['PWD'], '/tmp')
220
239
  self.assertEqual(result.returncode, 0)
221
240
  self.assertEqual(result.stdout, ['line1', 'line2'])
222
241
  self.assertEqual(result.stderr, [])
@@ -254,13 +273,13 @@ class TestRunWithOutput(unittest.TestCase):
254
273
  mock_popen_cls.return_value = mock_process
255
274
 
256
275
  result = run_with_output(['git', 'status'], cwd='/tmp')
257
- mock_popen_cls.assert_called_once_with(
258
- ['git', 'status'],
259
- cwd='/tmp',
260
- stdout=subprocess.PIPE,
261
- stderr=subprocess.PIPE,
262
- text=True,
263
- )
276
+ call = mock_popen_cls.call_args
277
+ self.assertEqual(call.args, (['git', 'status'],))
278
+ self.assertEqual(call.kwargs['cwd'], '/tmp')
279
+ self.assertEqual(call.kwargs['stdout'], subprocess.PIPE)
280
+ self.assertEqual(call.kwargs['stderr'], subprocess.PIPE)
281
+ self.assertEqual(call.kwargs['text'], True)
282
+ self.assertEqual(call.kwargs['env']['PWD'], '/tmp')
264
283
  self.assertEqual(result.returncode, 0)
265
284
 
266
285
  @mock.patch('subprocess.Popen')
@@ -350,6 +350,28 @@ class TestActionMismatch(unittest.TestCase):
350
350
  self.assertEqual(calls[2][0][0],
351
351
  ['git', 'restore', 'file.txt'])
352
352
 
353
+ # --- add -> edit (keep as add, file is new to depot) ---
354
+ @mock.patch('git_p4son.perforce.get_changelist_for_file', return_value=('100', 'add'))
355
+ @mock.patch('git_p4son.perforce.run')
356
+ def test_add_to_edit_same_cl_keeps_add(self, mock_run, mock_check):
357
+ mock_run.return_value = make_run_result()
358
+ changes = LocalChanges()
359
+ changes.mods = ['file.txt']
360
+ include_changes_in_changelist(changes, '100', '/ws')
361
+ mock_run.assert_not_called()
362
+
363
+ @mock.patch('git_p4son.perforce.get_changelist_for_file', return_value=('200', 'add'))
364
+ @mock.patch('git_p4son.perforce.run')
365
+ def test_add_to_edit_different_cl_reopens(self, mock_run, mock_check):
366
+ mock_run.return_value = make_run_result()
367
+ changes = LocalChanges()
368
+ changes.mods = ['file.txt']
369
+ include_changes_in_changelist(changes, '100', '/ws')
370
+ mock_run.assert_called_once_with(
371
+ ['p4', 'reopen', '-c', '100', 'file.txt'],
372
+ cwd='/ws', dry_run=False,
373
+ )
374
+
353
375
  # --- add -> delete (revert only, no reopen) ---
354
376
  @mock.patch('git_p4son.perforce.get_changelist_for_file', return_value=('100', 'add'))
355
377
  @mock.patch('git_p4son.perforce.run')
@@ -7,7 +7,6 @@ from unittest import mock
7
7
  from git_p4son.common import CommandError, RunError
8
8
  from git_p4son.perforce import (
9
9
  P4SyncOutputProcessor,
10
- get_file_count_to_sync,
11
10
  get_latest_changelist,
12
11
  get_writable_files,
13
12
  p4_get_opened_files,
@@ -317,62 +316,31 @@ class TestGetLatestChangelist(unittest.TestCase):
317
316
  get_latest_changelist('//myclient', '/ws')
318
317
 
319
318
 
320
- class TestGetFileCountToSync(unittest.TestCase):
321
- @mock.patch('git_p4son.perforce.run')
322
- def test_returns_count(self, mock_run):
323
- mock_run.return_value = make_run_result(stdout=[
324
- '//depot/a.txt - added',
325
- '//depot/b.txt - updating',
326
- ])
327
- count = get_file_count_to_sync(12345, '//myclient', '/ws')
328
- self.assertEqual(count, 2)
329
-
330
- @mock.patch('git_p4son.perforce.run')
331
- def test_failure(self, mock_run):
332
- mock_run.side_effect = RunError('p4 sync -n failed')
333
- with self.assertRaises(RunError):
334
- get_file_count_to_sync(12345, '//myclient', '/ws')
335
-
336
-
337
319
  class TestP4SyncOutputProcessor(unittest.TestCase):
338
320
  def test_tracks_added_file(self):
339
- processor = P4SyncOutputProcessor(10)
321
+ processor = P4SyncOutputProcessor()
340
322
  processor('//depot/foo.txt#1 - added as /ws/foo.txt', sys.stdout)
341
323
  self.assertEqual(processor.stats['add'], 1)
342
324
  self.assertEqual(processor.synced_file_count, 1)
343
325
 
344
326
  def test_tracks_deleted_file(self):
345
- processor = P4SyncOutputProcessor(10)
327
+ processor = P4SyncOutputProcessor()
346
328
  processor('//depot/foo.txt#2 - deleted as /ws/foo.txt', sys.stdout)
347
329
  self.assertEqual(processor.stats['del'], 1)
348
330
 
349
331
  def test_up_to_date_message(self):
350
- processor = P4SyncOutputProcessor(10)
332
+ processor = P4SyncOutputProcessor()
351
333
  processor('//...@12345 - file(s) up-to-date.', sys.stdout)
352
334
  self.assertEqual(processor.synced_file_count, 0)
353
335
 
354
336
 
355
337
  class TestP4Sync(unittest.TestCase):
356
338
  @mock.patch('git_p4son.sync.run_with_output')
357
- @mock.patch('git_p4son.sync.get_file_count_to_sync')
358
- def test_success(self, mock_count, mock_rwo):
359
- mock_count.return_value = 2
339
+ def test_success(self, mock_rwo):
360
340
  mock_rwo.return_value = make_run_result()
361
341
  result = p4_sync(12345, 'test', False, '//myclient', '/ws')
362
342
  self.assertTrue(result)
363
343
 
364
- @mock.patch('git_p4son.sync.get_file_count_to_sync')
365
- def test_up_to_date(self, mock_count):
366
- mock_count.return_value = 0
367
- result = p4_sync(12345, 'test', False, '//myclient', '/ws')
368
- self.assertTrue(result)
369
-
370
- @mock.patch('git_p4son.sync.get_file_count_to_sync')
371
- def test_count_failure(self, mock_count):
372
- mock_count.side_effect = RunError('p4 sync -n failed')
373
- with self.assertRaises(RunError):
374
- p4_sync(12345, 'test', False, '//myclient', '/ws')
375
-
376
344
 
377
345
  class TestSyncCommand(unittest.TestCase):
378
346
  @mock.patch('git_p4son.sync.commit')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes