git-p4son 0.3.1__tar.gz → 0.3.2__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 (54) hide show
  1. {git_p4son-0.3.1 → git_p4son-0.3.2}/PKG-INFO +81 -3
  2. {git_p4son-0.3.1 → git_p4son-0.3.2}/README.md +80 -2
  3. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/cli.py +38 -0
  5. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/complete.py +4 -0
  6. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/perforce.py +27 -0
  7. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/sync.py +30 -9
  8. git_p4son-0.3.2/git_p4son/sync_split.py +148 -0
  9. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son.egg-info/PKG-INFO +81 -3
  10. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son.egg-info/SOURCES.txt +3 -1
  11. {git_p4son-0.3.1 → git_p4son-0.3.2}/pyproject.toml +1 -1
  12. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_complete.py +3 -2
  13. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_perforce.py +48 -2
  14. git_p4son-0.3.2/tests/test_sync_split.py +260 -0
  15. {git_p4son-0.3.1 → git_p4son-0.3.2}/LICENSE +0 -0
  16. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/__main__.py +0 -0
  17. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/alias.py +0 -0
  18. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/changelist_store.py +0 -0
  19. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/common.py +0 -0
  20. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/completions/_git-p4son +0 -0
  21. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/completions/git-p4son.bash +0 -0
  22. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/completions/git-p4son.ps1 +0 -0
  23. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/config.py +0 -0
  24. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/git.py +0 -0
  25. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/hooks.py +0 -0
  26. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/init.py +0 -0
  27. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/lib.py +0 -0
  28. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/list_changes.py +0 -0
  29. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/log.py +0 -0
  30. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/new.py +0 -0
  31. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/review.py +0 -0
  32. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/state.py +0 -0
  33. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son/update.py +0 -0
  34. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son.egg-info/dependency_links.txt +0 -0
  35. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son.egg-info/entry_points.txt +0 -0
  36. {git_p4son-0.3.1 → git_p4son-0.3.2}/git_p4son.egg-info/top_level.txt +0 -0
  37. {git_p4son-0.3.1 → git_p4son-0.3.2}/setup.cfg +0 -0
  38. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_alias.py +0 -0
  39. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_changelist_store.py +0 -0
  40. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_cli.py +0 -0
  41. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_common.py +0 -0
  42. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_config.py +0 -0
  43. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_git.py +0 -0
  44. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_hooks.py +0 -0
  45. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_init.py +0 -0
  46. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_lib_changelist.py +0 -0
  47. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_lib_edit.py +0 -0
  48. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_lib_review.py +0 -0
  49. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_list_changes.py +0 -0
  50. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_log.py +0 -0
  51. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_new.py +0 -0
  52. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_review.py +0 -0
  53. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_state.py +0 -0
  54. {git_p4son-0.3.1 → git_p4son-0.3.2}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -233,8 +233,8 @@ commit any files that need attention.
233
233
 
234
234
  ## Usage
235
235
 
236
- git-p4son provides eight commands: `init`, `sync`, `new`, `update`, `review`, `list-changes`, `alias`,
237
- and `completion`.
236
+ git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
237
+ `alias`, and `completion`.
238
238
 
239
239
  To see help for any command, use `-h`:
240
240
 
@@ -315,6 +315,54 @@ changelists is given, the pre-sync hooks run once, before the first sync.
315
315
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs [hooks](#hooks)
316
316
  from `.git-p4son/hooks/post-sync/`.
317
317
 
318
+ ### Sync-Split Command
319
+
320
+ Sync forward from the last synced changelist, splitting selected users' changelists into their own commits:
321
+
322
+ ```sh
323
+ git p4son sync-split [changelist] [--user NAME ...] [--dry-run]
324
+ ```
325
+
326
+ Syncing straight to the latest changelist lumps your own submitted changelists together with everyone
327
+ else's in a single commit. `sync-split` resolves the sequence for you: it looks up the last synced
328
+ changelist in the git log, asks Perforce which changelists the selected users submitted since then, and
329
+ syncs the changelist submitted immediately before each of those first. The result is a git history where
330
+ every changelist those users submitted is a commit containing nothing but their own change.
331
+
332
+ By default the selected user is you (the current p4 user), which covers the common case of reviewing your
333
+ own submits one at a time. Repeat `--user` to split out several people's changelists in the same run.
334
+
335
+ It is equivalent to working out the numbers by hand and running
336
+ `git p4son sync <before-theirs> <theirs> ... head`, and it delegates to `sync` once the sequence is
337
+ resolved, so [pre-sync and post-sync hooks](#hooks), writable-file merging, and the clean-workspace checks
338
+ all behave exactly as they do for `sync`.
339
+
340
+ **Arguments:**
341
+ - `changelist` (optional): Changelist number to sync up to, or `head` for the latest. Omit to sync to the
342
+ latest changelist affecting the workspace.
343
+
344
+ **Options:**
345
+ - `-u, --user NAME`: Perforce user whose changelists to split into their own commits. Repeat the flag to
346
+ select several users. Defaults to the current p4 user (from `p4 info`).
347
+ - `-n, --dry-run`: Print the resolved sync sequence without syncing.
348
+
349
+ **Examples:**
350
+ ```sh
351
+ git p4son sync-split # sync to latest, your own changelists split out
352
+ git p4son sync-split head # same, explicit
353
+ git p4son sync-split 12345 # stop at changelist 12345
354
+ git p4son sync-split --dry-run # show the sequence that would be synced
355
+ git p4son sync-split -u alice # split out alice's changelists instead of yours
356
+ git p4son sync-split -u alice -u bob # split out both alice's and bob's changelists
357
+ ```
358
+
359
+ For example, with the last sync at CL 100 and your own changelists at 102 and 105, `sync-split` resolves
360
+ to `git p4son sync 101 102 104 105 106`, producing five commits where CL 102 and CL 105 each hold only
361
+ your change.
362
+
363
+ `sync-split` only moves forward: it requires a previous sync to start from, and refuses a target older
364
+ than the last synced changelist (use `git p4son sync --force` for that).
365
+
318
366
  ### New Command
319
367
 
320
368
  Create a new Perforce changelist and add changed files to it. Description will contain an enumerated list of git commits since the base branch.
@@ -581,6 +629,36 @@ before the first sync.
581
629
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs executable hooks from
582
630
  `.git-p4son/hooks/post-sync/`.
583
631
 
632
+ ### Example hooks
633
+
634
+ The [`examples/hooks/`](examples/hooks/) directory holds ready-made hooks. Nothing installs them for you:
635
+ symlink or copy the one you want into the matching hook directory in your workspace. Symlinking keeps the hook
636
+ up to date with the clone it points at, and on macOS and Linux the executable bit comes from the file in the
637
+ clone, so no `chmod` is needed.
638
+
639
+ #### block-while-running (pre-sync)
640
+
641
+ Aborts the sync while a named process is running, so a sync cannot swap out assets that a running editor still
642
+ has loaded:
643
+
644
+ ```sh
645
+ ln -s /path/to/git-p4son/examples/hooks/pre-sync/block-while-running.py .git-p4son/hooks/pre-sync/
646
+ ```
647
+
648
+ Which processes block a sync is read from your workspace's own `.git-p4son/config.toml`, so a symlinked hook
649
+ stays per-project:
650
+
651
+ ```toml
652
+ [hooks.block-while-running]
653
+ processes = ["UnrealEditor", "UnrealLightmass"]
654
+ ```
655
+
656
+ Names are matched with any `.exe` suffix stripped and case ignored, so a single entry covers
657
+ `UnrealEditor.exe` on Windows and `UnrealEditor` on macOS. Without that table the hook checks nothing and the
658
+ sync proceeds; a table that is present but malformed aborts the sync rather than silently skipping the check.
659
+
660
+ Set `GIT_P4SON_SKIP_PROCESS_CHECK=1` to run one sync without the check.
661
+
584
662
  ## Shell Completions
585
663
 
586
664
  Tab completion is available for bash, zsh, and PowerShell, including commands, flags, and dynamic alias names.
@@ -210,8 +210,8 @@ commit any files that need attention.
210
210
 
211
211
  ## Usage
212
212
 
213
- git-p4son provides eight commands: `init`, `sync`, `new`, `update`, `review`, `list-changes`, `alias`,
214
- and `completion`.
213
+ git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
214
+ `alias`, and `completion`.
215
215
 
216
216
  To see help for any command, use `-h`:
217
217
 
@@ -292,6 +292,54 @@ changelists is given, the pre-sync hooks run once, before the first sync.
292
292
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs [hooks](#hooks)
293
293
  from `.git-p4son/hooks/post-sync/`.
294
294
 
295
+ ### Sync-Split Command
296
+
297
+ Sync forward from the last synced changelist, splitting selected users' changelists into their own commits:
298
+
299
+ ```sh
300
+ git p4son sync-split [changelist] [--user NAME ...] [--dry-run]
301
+ ```
302
+
303
+ Syncing straight to the latest changelist lumps your own submitted changelists together with everyone
304
+ else's in a single commit. `sync-split` resolves the sequence for you: it looks up the last synced
305
+ changelist in the git log, asks Perforce which changelists the selected users submitted since then, and
306
+ syncs the changelist submitted immediately before each of those first. The result is a git history where
307
+ every changelist those users submitted is a commit containing nothing but their own change.
308
+
309
+ By default the selected user is you (the current p4 user), which covers the common case of reviewing your
310
+ own submits one at a time. Repeat `--user` to split out several people's changelists in the same run.
311
+
312
+ It is equivalent to working out the numbers by hand and running
313
+ `git p4son sync <before-theirs> <theirs> ... head`, and it delegates to `sync` once the sequence is
314
+ resolved, so [pre-sync and post-sync hooks](#hooks), writable-file merging, and the clean-workspace checks
315
+ all behave exactly as they do for `sync`.
316
+
317
+ **Arguments:**
318
+ - `changelist` (optional): Changelist number to sync up to, or `head` for the latest. Omit to sync to the
319
+ latest changelist affecting the workspace.
320
+
321
+ **Options:**
322
+ - `-u, --user NAME`: Perforce user whose changelists to split into their own commits. Repeat the flag to
323
+ select several users. Defaults to the current p4 user (from `p4 info`).
324
+ - `-n, --dry-run`: Print the resolved sync sequence without syncing.
325
+
326
+ **Examples:**
327
+ ```sh
328
+ git p4son sync-split # sync to latest, your own changelists split out
329
+ git p4son sync-split head # same, explicit
330
+ git p4son sync-split 12345 # stop at changelist 12345
331
+ git p4son sync-split --dry-run # show the sequence that would be synced
332
+ git p4son sync-split -u alice # split out alice's changelists instead of yours
333
+ git p4son sync-split -u alice -u bob # split out both alice's and bob's changelists
334
+ ```
335
+
336
+ For example, with the last sync at CL 100 and your own changelists at 102 and 105, `sync-split` resolves
337
+ to `git p4son sync 101 102 104 105 106`, producing five commits where CL 102 and CL 105 each hold only
338
+ your change.
339
+
340
+ `sync-split` only moves forward: it requires a previous sync to start from, and refuses a target older
341
+ than the last synced changelist (use `git p4son sync --force` for that).
342
+
295
343
  ### New Command
296
344
 
297
345
  Create a new Perforce changelist and add changed files to it. Description will contain an enumerated list of git commits since the base branch.
@@ -558,6 +606,36 @@ before the first sync.
558
606
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs executable hooks from
559
607
  `.git-p4son/hooks/post-sync/`.
560
608
 
609
+ ### Example hooks
610
+
611
+ The [`examples/hooks/`](examples/hooks/) directory holds ready-made hooks. Nothing installs them for you:
612
+ symlink or copy the one you want into the matching hook directory in your workspace. Symlinking keeps the hook
613
+ up to date with the clone it points at, and on macOS and Linux the executable bit comes from the file in the
614
+ clone, so no `chmod` is needed.
615
+
616
+ #### block-while-running (pre-sync)
617
+
618
+ Aborts the sync while a named process is running, so a sync cannot swap out assets that a running editor still
619
+ has loaded:
620
+
621
+ ```sh
622
+ ln -s /path/to/git-p4son/examples/hooks/pre-sync/block-while-running.py .git-p4son/hooks/pre-sync/
623
+ ```
624
+
625
+ Which processes block a sync is read from your workspace's own `.git-p4son/config.toml`, so a symlinked hook
626
+ stays per-project:
627
+
628
+ ```toml
629
+ [hooks.block-while-running]
630
+ processes = ["UnrealEditor", "UnrealLightmass"]
631
+ ```
632
+
633
+ Names are matched with any `.exe` suffix stripped and case ignored, so a single entry covers
634
+ `UnrealEditor.exe` on Windows and `UnrealEditor` on macOS. Without that table the hook checks nothing and the
635
+ sync proceeds; a table that is present but malformed aborts the sync rather than silently skipping the check.
636
+
637
+ Set `GIT_P4SON_SKIP_PROCESS_CHECK=1` to run one sync without the check.
638
+
561
639
  ## Shell Completions
562
640
 
563
641
  Tab completion is available for bash, zsh, and PowerShell, including commands, flags, and dynamic alias names.
@@ -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.3.1"
10
+ __version__ = "0.3.2"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -9,6 +9,7 @@ import time
9
9
  from importlib.resources import files
10
10
  from . import __version__
11
11
  from .sync import sync_command
12
+ from .sync_split import sync_split_command
12
13
  from .new import new_command
13
14
  from .update import update_command
14
15
  from .list_changes import list_changes_command
@@ -36,6 +37,9 @@ Examples:
36
37
  git-p4son sync 123 156 178 # Sync each changelist in sequence, one commit each
37
38
  git-p4son sync 123 156 head # Sync 123, 156, then the latest changelist
38
39
  git-p4son sync last-synced # Re-sync the last synced changelist
40
+ git-p4son sync-split # Sync to latest, your own changelists split out
41
+ git-p4son sync-split 12345 # Same, but stop at changelist 12345
42
+ git-p4son sync-split -u alice -u bob # Split out alice's and bob's changelists
39
43
  git-p4son new -m "Fix bug" # Create changelist, alias defaults to branch name
40
44
  git-p4son new -m "Fix bug" --review # Create changelist, create Swarm review
41
45
  git-p4son new -m "Fix bug" --no-alias # Create changelist without saving an alias
@@ -96,6 +100,38 @@ Examples:
96
100
  help='Allow syncing to changelists older than the current one.'
97
101
  )
98
102
 
103
+ # Sync-split subcommand
104
+ sync_split_parser = subparsers.add_parser(
105
+ 'sync-split',
106
+ help='Sync forward, splitting a user\'s changelists into own commits',
107
+ description='Sync from the last synced changelist up to a target '
108
+ 'changelist (the latest by default), syncing the changelist submitted '
109
+ 'just before each of the selected users\' submits first so that every '
110
+ 'changelist they submitted lands in a git commit of its own.'
111
+ )
112
+ sync_split_parser.add_argument(
113
+ 'changelist',
114
+ nargs='?',
115
+ default=None,
116
+ metavar='CHANGELIST',
117
+ help='Changelist number to sync up to, or "head" for the latest. '
118
+ 'Omit to sync to the latest changelist affecting the workspace'
119
+ )
120
+ sync_split_parser.add_argument(
121
+ '-u', '--user',
122
+ action='append',
123
+ default=None,
124
+ metavar='NAME',
125
+ help='Perforce user whose changelists to split into their own '
126
+ 'commits. Repeat to select several users. '
127
+ 'Defaults to the current p4 user'
128
+ )
129
+ sync_split_parser.add_argument(
130
+ '-n', '--dry-run',
131
+ action='store_true',
132
+ help='Print the resolved sync sequence without syncing'
133
+ )
134
+
99
135
  # New subcommand
100
136
  new_parser = subparsers.add_parser(
101
137
  'new',
@@ -429,6 +465,8 @@ def run_command(args: argparse.Namespace) -> int:
429
465
 
430
466
  if args.command == 'sync':
431
467
  return sync_command(args)
468
+ elif args.command == 'sync-split':
469
+ return sync_split_command(args)
432
470
  elif args.command == 'new':
433
471
  return new_command(args)
434
472
  elif args.command == 'update':
@@ -117,6 +117,10 @@ def _complete_positional(command, subcommand, positional_count,
117
117
  return _filter(
118
118
  [('head', 'Sync to the latest changelist')], prefix)
119
119
 
120
+ if command == 'sync-split' and positional_count == 0:
121
+ return _filter(
122
+ [('head', 'Sync to the latest changelist')], prefix)
123
+
120
124
  if command == 'update' and positional_count == 0:
121
125
  branch_candidates = _get_branch_candidates(prefix, workspace_dir)
122
126
  return branch_candidates + _filter(aliases, prefix)
@@ -204,6 +204,33 @@ def get_latest_changelist(depot_root: str, workspace_dir: str) -> int:
204
204
  return int(fields['change'])
205
205
 
206
206
 
207
+ @dataclass
208
+ class P4Change:
209
+ """A submitted changelist affecting the depot root."""
210
+
211
+ change: int
212
+ user: str
213
+
214
+
215
+ def get_submitted_changes(depot_root: str, start: int, end: int,
216
+ workspace_dir: str) -> list[P4Change]:
217
+ """List changelists submitted to the depot root in [start, end], oldest first."""
218
+ res = run(['p4', '-ztag', 'changes', '-s', 'submitted',
219
+ f'{depot_root}/...@{start},@{end}'], cwd=workspace_dir)
220
+ records = parse_ztag_multi_output(res.stdout)
221
+ changes = [P4Change(change=int(r['change']), user=r.get('user', ''))
222
+ for r in records if 'change' in r]
223
+ return sorted(changes, key=lambda c: c.change)
224
+
225
+
226
+ def get_p4_user(workspace_dir: str) -> str | None:
227
+ """Get the Perforce user name of the current connection."""
228
+ res = run(['p4', '-ztag', 'info'], cwd=workspace_dir)
229
+ fields = parse_ztag_output(res.stdout)
230
+ user = fields.get('userName')
231
+ return user or None
232
+
233
+
207
234
  # --- file operations ---
208
235
 
209
236
  def get_changelist_for_file(filename: str, workspace_dir: str) -> tuple[str, str] | None:
@@ -30,6 +30,7 @@ from .perforce import (
30
30
  p4_fstat_file_info,
31
31
  p4_get_opened_files,
32
32
  p4_sync_preview,
33
+ P4ClientSpec,
33
34
  P4SyncOutputProcessor,
34
35
  P4SyncPreviewFile,
35
36
  )
@@ -554,28 +555,48 @@ def _sync_pass(changelist: int, label: str, depot_root: str,
554
555
  return prep
555
556
 
556
557
 
557
- def sync_command(args: argparse.Namespace) -> int:
558
- """Execute the sync command."""
559
- workspace_dir = args.workspace_dir
560
- invocation_dir = vars(args).get('invocation_dir', workspace_dir)
558
+ @dataclass
559
+ class ResolvedDepot:
560
+ """The configured depot root with any placeholder expanded, plus the
561
+ client spec it was resolved against."""
562
+ depot_root: str
563
+ client_spec: P4ClientSpec | None
561
564
 
565
+
566
+ def resolve_depot_root(workspace_dir: str) -> ResolvedDepot | None:
567
+ """Resolve the configured depot root, or None (with an error logged).
568
+
569
+ The client spec is queried once here: its name resolves a $(workspace)
570
+ placeholder in the depot root, and its line-ending/clobber options feed
571
+ the writable-file handling in sync.
572
+ """
562
573
  log.heading('Finding depot root')
563
574
  depot_root = get_depot_root(workspace_dir)
564
575
  if not depot_root:
565
576
  log.error('No depot root configured. Run "git p4son init" first.')
566
- return 1
577
+ return None
567
578
 
568
- # The client spec is queried once here: its name resolves a $(workspace)
569
- # placeholder in the depot root, and its line-ending/clobber options feed
570
- # the writable-file handling further down.
571
579
  client_spec = get_client_spec(workspace_dir)
572
580
  if WORKSPACE_PLACEHOLDER in depot_root and not client_spec:
573
581
  log.error('Cannot resolve $(workspace) in depot root: not inside a '
574
582
  'Perforce workspace')
575
- return 1
583
+ return None
576
584
  if client_spec:
577
585
  depot_root = expand_depot_root(depot_root, client_spec.name)
578
586
  log.success(depot_root)
587
+ return ResolvedDepot(depot_root=depot_root, client_spec=client_spec)
588
+
589
+
590
+ def sync_command(args: argparse.Namespace) -> int:
591
+ """Execute the sync command."""
592
+ workspace_dir = args.workspace_dir
593
+ invocation_dir = vars(args).get('invocation_dir', workspace_dir)
594
+
595
+ resolved = resolve_depot_root(workspace_dir)
596
+ if resolved is None:
597
+ return 1
598
+ depot_root = resolved.depot_root
599
+ client_spec = resolved.client_spec
579
600
 
580
601
  log.heading('Checking git workspace')
581
602
  dirty_files = get_dirty_files(workspace_dir)
@@ -0,0 +1,148 @@
1
+ """
2
+ Sync-split command implementation for git-p4son.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from .log import log
8
+ from .perforce import (
9
+ P4Change,
10
+ get_latest_changelist,
11
+ get_p4_user,
12
+ get_submitted_changes,
13
+ )
14
+ from .sync import git_last_sync, resolve_depot_root, sync_command
15
+
16
+
17
+ def build_sync_targets(changes: list[P4Change], users: list[str],
18
+ last_synced: int, upper: int) -> list[int]:
19
+ """Build the sync sequence that splits out the given users' changelists.
20
+
21
+ changes is every submitted changelist affecting the depot root in
22
+ [last_synced, upper], oldest first. Each changelist submitted by one of
23
+ users gets the changelist submitted just before it synced first, so that
24
+ submit lands in a commit containing nothing else. Changelists at or below
25
+ last_synced are already in git and dropped, and the sequence always ends
26
+ at upper.
27
+ """
28
+ targets: list[int] = []
29
+ lowered = {u.lower() for u in users}
30
+ for i, change in enumerate(changes):
31
+ if change.change <= last_synced or change.user.lower() not in lowered:
32
+ continue
33
+ last = targets[-1] if targets else last_synced
34
+ if i > 0 and changes[i - 1].change > last:
35
+ targets.append(changes[i - 1].change)
36
+ last = targets[-1]
37
+ if change.change > last:
38
+ targets.append(change.change)
39
+ if not targets or upper > targets[-1]:
40
+ targets.append(upper)
41
+ return targets
42
+
43
+
44
+ def _resolve_users(args: argparse.Namespace, workspace_dir: str) -> list[str]:
45
+ """Resolve the users whose changelists to split out, or [] on failure.
46
+
47
+ Defaults to the current Perforce user when no --user was given. Repeated
48
+ names are collapsed, keeping the order they were given in.
49
+ """
50
+ log.heading('Finding Perforce users')
51
+ given = args.user or []
52
+ if not given:
53
+ current = get_p4_user(workspace_dir)
54
+ if not current:
55
+ log.error('Cannot determine the current Perforce user. '
56
+ 'Pass --user NAME.')
57
+ return []
58
+ given = [current]
59
+
60
+ users: list[str] = []
61
+ seen: set[str] = set()
62
+ for user in given:
63
+ if user.lower() not in seen:
64
+ seen.add(user.lower())
65
+ users.append(user)
66
+ log.success(', '.join(users))
67
+ return users
68
+
69
+
70
+ def sync_split_command(args: argparse.Namespace) -> int:
71
+ """Execute the sync-split command."""
72
+ workspace_dir = args.workspace_dir
73
+
74
+ resolved = resolve_depot_root(workspace_dir)
75
+ if resolved is None:
76
+ return 1
77
+ depot_root = resolved.depot_root
78
+
79
+ log.heading('Finding last synced changelist')
80
+ last_sync = git_last_sync(workspace_dir)
81
+ if not last_sync:
82
+ log.error('No previous sync found. Run "git p4son sync" once to '
83
+ 'establish a starting point.')
84
+ return 1
85
+ log.success(f'CL {last_sync.changelist}')
86
+ last_synced = last_sync.changelist
87
+
88
+ if args.changelist is None or args.changelist.lower() == 'head':
89
+ log.heading('Finding latest changelist')
90
+ upper = get_latest_changelist(depot_root, workspace_dir)
91
+ log.success(f'CL {upper}')
92
+ else:
93
+ try:
94
+ upper = int(args.changelist)
95
+ except ValueError:
96
+ log.error(f'Invalid changelist number: {args.changelist}')
97
+ return 1
98
+
99
+ # sync-split only ever moves forward from the last synced changelist: the
100
+ # range it scans for the users' submits starts there.
101
+ if upper == last_synced:
102
+ log.info('Already synced, nothing to do.')
103
+ return 0
104
+ if upper < last_synced:
105
+ log.error(f'Cannot sync back to CL {upper} '
106
+ f'(currently at CL {last_synced}). '
107
+ 'Use "git p4son sync --force" to sync to an older '
108
+ 'changelist.')
109
+ return 1
110
+
111
+ users = _resolve_users(args, workspace_dir)
112
+ if not users:
113
+ return 1
114
+
115
+ # One query covers both jobs: which changelists in the range belong to
116
+ # the given users, and which changelist was submitted immediately before
117
+ # each of them (its predecessor in this same list).
118
+ log.heading(f'Finding changelists submitted to {depot_root} '
119
+ f'in CL {last_synced}..{upper}')
120
+ changes = get_submitted_changes(depot_root, last_synced, upper,
121
+ workspace_dir)
122
+ log.success(f'{len(changes)} changelists')
123
+
124
+ lowered = {u.lower() for u in users}
125
+ matched = [c for c in changes
126
+ if c.change > last_synced and c.user.lower() in lowered]
127
+ log.heading('Finding changelists to split into their own commits')
128
+ if matched:
129
+ for change in matched:
130
+ log.info(f'CL {change.change} ({change.user})')
131
+ label = 'changelist' if len(matched) == 1 else 'changelists'
132
+ log.success(f'{len(matched)} {label} to split out')
133
+ else:
134
+ log.warning('None found, syncing to the target changelist only')
135
+
136
+ targets = build_sync_targets(changes, users, last_synced, upper)
137
+
138
+ log.heading('Sync sequence')
139
+ log.success(' '.join(str(cl) for cl in targets))
140
+
141
+ if args.dry_run:
142
+ log.info(f'Would run: git p4son sync '
143
+ f'{" ".join(str(cl) for cl in targets)}')
144
+ return 0
145
+
146
+ args.changelist = [str(cl) for cl in targets]
147
+ args.force = False
148
+ return sync_command(args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -233,8 +233,8 @@ commit any files that need attention.
233
233
 
234
234
  ## Usage
235
235
 
236
- git-p4son provides eight commands: `init`, `sync`, `new`, `update`, `review`, `list-changes`, `alias`,
237
- and `completion`.
236
+ git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
237
+ `alias`, and `completion`.
238
238
 
239
239
  To see help for any command, use `-h`:
240
240
 
@@ -315,6 +315,54 @@ changelists is given, the pre-sync hooks run once, before the first sync.
315
315
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs [hooks](#hooks)
316
316
  from `.git-p4son/hooks/post-sync/`.
317
317
 
318
+ ### Sync-Split Command
319
+
320
+ Sync forward from the last synced changelist, splitting selected users' changelists into their own commits:
321
+
322
+ ```sh
323
+ git p4son sync-split [changelist] [--user NAME ...] [--dry-run]
324
+ ```
325
+
326
+ Syncing straight to the latest changelist lumps your own submitted changelists together with everyone
327
+ else's in a single commit. `sync-split` resolves the sequence for you: it looks up the last synced
328
+ changelist in the git log, asks Perforce which changelists the selected users submitted since then, and
329
+ syncs the changelist submitted immediately before each of those first. The result is a git history where
330
+ every changelist those users submitted is a commit containing nothing but their own change.
331
+
332
+ By default the selected user is you (the current p4 user), which covers the common case of reviewing your
333
+ own submits one at a time. Repeat `--user` to split out several people's changelists in the same run.
334
+
335
+ It is equivalent to working out the numbers by hand and running
336
+ `git p4son sync <before-theirs> <theirs> ... head`, and it delegates to `sync` once the sequence is
337
+ resolved, so [pre-sync and post-sync hooks](#hooks), writable-file merging, and the clean-workspace checks
338
+ all behave exactly as they do for `sync`.
339
+
340
+ **Arguments:**
341
+ - `changelist` (optional): Changelist number to sync up to, or `head` for the latest. Omit to sync to the
342
+ latest changelist affecting the workspace.
343
+
344
+ **Options:**
345
+ - `-u, --user NAME`: Perforce user whose changelists to split into their own commits. Repeat the flag to
346
+ select several users. Defaults to the current p4 user (from `p4 info`).
347
+ - `-n, --dry-run`: Print the resolved sync sequence without syncing.
348
+
349
+ **Examples:**
350
+ ```sh
351
+ git p4son sync-split # sync to latest, your own changelists split out
352
+ git p4son sync-split head # same, explicit
353
+ git p4son sync-split 12345 # stop at changelist 12345
354
+ git p4son sync-split --dry-run # show the sequence that would be synced
355
+ git p4son sync-split -u alice # split out alice's changelists instead of yours
356
+ git p4son sync-split -u alice -u bob # split out both alice's and bob's changelists
357
+ ```
358
+
359
+ For example, with the last sync at CL 100 and your own changelists at 102 and 105, `sync-split` resolves
360
+ to `git p4son sync 101 102 104 105 106`, producing five commits where CL 102 and CL 105 each hold only
361
+ your change.
362
+
363
+ `sync-split` only moves forward: it requires a previous sync to start from, and refuses a target older
364
+ than the last synced changelist (use `git p4son sync --force` for that).
365
+
318
366
  ### New Command
319
367
 
320
368
  Create a new Perforce changelist and add changed files to it. Description will contain an enumerated list of git commits since the base branch.
@@ -581,6 +629,36 @@ before the first sync.
581
629
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs executable hooks from
582
630
  `.git-p4son/hooks/post-sync/`.
583
631
 
632
+ ### Example hooks
633
+
634
+ The [`examples/hooks/`](examples/hooks/) directory holds ready-made hooks. Nothing installs them for you:
635
+ symlink or copy the one you want into the matching hook directory in your workspace. Symlinking keeps the hook
636
+ up to date with the clone it points at, and on macOS and Linux the executable bit comes from the file in the
637
+ clone, so no `chmod` is needed.
638
+
639
+ #### block-while-running (pre-sync)
640
+
641
+ Aborts the sync while a named process is running, so a sync cannot swap out assets that a running editor still
642
+ has loaded:
643
+
644
+ ```sh
645
+ ln -s /path/to/git-p4son/examples/hooks/pre-sync/block-while-running.py .git-p4son/hooks/pre-sync/
646
+ ```
647
+
648
+ Which processes block a sync is read from your workspace's own `.git-p4son/config.toml`, so a symlinked hook
649
+ stays per-project:
650
+
651
+ ```toml
652
+ [hooks.block-while-running]
653
+ processes = ["UnrealEditor", "UnrealLightmass"]
654
+ ```
655
+
656
+ Names are matched with any `.exe` suffix stripped and case ignored, so a single entry covers
657
+ `UnrealEditor.exe` on Windows and `UnrealEditor` on macOS. Without that table the hook checks nothing and the
658
+ sync proceeds; a table that is present but malformed aborts the sync rather than silently skipping the check.
659
+
660
+ Set `GIT_P4SON_SKIP_PROCESS_CHECK=1` to run one sync without the check.
661
+
584
662
  ## Shell Completions
585
663
 
586
664
  Tab completion is available for bash, zsh, and PowerShell, including commands, flags, and dynamic alias names.
@@ -20,6 +20,7 @@ git_p4son/perforce.py
20
20
  git_p4son/review.py
21
21
  git_p4son/state.py
22
22
  git_p4son/sync.py
23
+ git_p4son/sync_split.py
23
24
  git_p4son/update.py
24
25
  git_p4son.egg-info/PKG-INFO
25
26
  git_p4son.egg-info/SOURCES.txt
@@ -47,4 +48,5 @@ tests/test_new.py
47
48
  tests/test_perforce.py
48
49
  tests/test_review.py
49
50
  tests/test_state.py
50
- tests/test_sync.py
51
+ tests/test_sync.py
52
+ tests/test_sync_split.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-p4son"
7
- version = "0.3.1"
7
+ version = "0.3.2"
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"
@@ -136,7 +136,8 @@ class TestComplete(unittest.TestCase):
136
136
  self.assertIn('alias', names)
137
137
  self.assertIn('review', names)
138
138
  self.assertIn('init', names)
139
- self.assertEqual(len(names), 7)
139
+ self.assertIn('sync-split', names)
140
+ self.assertEqual(len(names), 8)
140
141
 
141
142
  def test_empty_excludes_complete(self, _ws, _aliases):
142
143
  result = _complete(self.parser, [''], workspace_dir='/ws')
@@ -151,7 +152,7 @@ class TestComplete(unittest.TestCase):
151
152
  def test_prefix_filters_commands(self, _ws, _aliases):
152
153
  result = _complete(self.parser, ['sy'], workspace_dir='/ws')
153
154
  names = self._names(result)
154
- self.assertEqual(names, ['sync'])
155
+ self.assertEqual(names, ['sync', 'sync-split'])
155
156
 
156
157
  def test_hidden_command_not_completed(self, _ws, _aliases):
157
158
  result = _complete(self.parser, ['c'], workspace_dir='/ws')
@@ -5,8 +5,9 @@ from unittest import mock
5
5
 
6
6
  from git_p4son.perforce import (
7
7
  P4ClientSpec, P4FileInfo, P4SyncPreviewFile, get_client_spec,
8
- is_binary_file_type, p4_fstat_file_info, p4_sync_preview,
9
- parse_ztag_multi_output, parse_ztag_output,
8
+ get_p4_user, get_submitted_changes, is_binary_file_type,
9
+ p4_fstat_file_info, p4_sync_preview, parse_ztag_multi_output,
10
+ parse_ztag_output,
10
11
  )
11
12
  from tests.helpers import make_run_result
12
13
 
@@ -253,3 +254,48 @@ class TestGetClientSpec(unittest.TestCase):
253
254
  ])
254
255
  spec = get_client_spec('/ws')
255
256
  self.assertIsNone(spec)
257
+
258
+
259
+ class TestGetSubmittedChanges(unittest.TestCase):
260
+ @mock.patch('git_p4son.perforce.run')
261
+ def test_parses_and_sorts_changes(self, mock_run):
262
+ # p4 lists newest first; the sequence builder needs oldest first.
263
+ mock_run.return_value = make_run_result(stdout=[
264
+ '... change 105',
265
+ '... user andreas',
266
+ '... desc Fix the thing',
267
+ '',
268
+ '... change 102',
269
+ '... user colleague',
270
+ '... desc Some other work',
271
+ '',
272
+ ])
273
+ changes = get_submitted_changes('//myclient', 100, 105, '/ws')
274
+ self.assertEqual([(c.change, c.user) for c in changes],
275
+ [(102, 'colleague'), (105, 'andreas')])
276
+ mock_run.assert_called_once_with(
277
+ ['p4', '-ztag', 'changes', '-s', 'submitted',
278
+ '//myclient/...@100,@105'], cwd='/ws')
279
+
280
+ @mock.patch('git_p4son.perforce.run')
281
+ def test_no_changes_in_range(self, mock_run):
282
+ mock_run.return_value = make_run_result(stdout=[])
283
+ self.assertEqual(get_submitted_changes('//myclient', 100, 105, '/ws'),
284
+ [])
285
+
286
+
287
+ class TestGetP4User(unittest.TestCase):
288
+ @mock.patch('git_p4son.perforce.run')
289
+ def test_reads_user_name(self, mock_run):
290
+ mock_run.return_value = make_run_result(stdout=[
291
+ '... userName andreas',
292
+ '... clientName my-ws',
293
+ ])
294
+ self.assertEqual(get_p4_user('/ws'), 'andreas')
295
+ mock_run.assert_called_once_with(
296
+ ['p4', '-ztag', 'info'], cwd='/ws')
297
+
298
+ @mock.patch('git_p4son.perforce.run')
299
+ def test_missing_user_name(self, mock_run):
300
+ mock_run.return_value = make_run_result(stdout=['... clientName x'])
301
+ self.assertIsNone(get_p4_user('/ws'))
@@ -0,0 +1,260 @@
1
+ """Tests for git_p4son.sync_split module."""
2
+
3
+ import unittest
4
+ from unittest import mock
5
+
6
+ from git_p4son.perforce import P4Change
7
+ from git_p4son.sync import LastSync, ResolvedDepot
8
+ from git_p4son.sync_split import (
9
+ build_sync_targets, sync_split_command,
10
+ )
11
+
12
+
13
+ def _changes(*pairs):
14
+ """Build a change list from (changelist, user) pairs."""
15
+ return [P4Change(change=cl, user=user) for cl, user in pairs]
16
+
17
+
18
+ class TestBuildSyncTargets(unittest.TestCase):
19
+ """The sequence must isolate each of the user's own changelists in a
20
+ commit of its own while staying strictly increasing."""
21
+
22
+ def test_single_own_changelist(self):
23
+ changes = _changes((100, 'other'), (101, 'other'),
24
+ (102, 'me'), (103, 'other'))
25
+ targets = build_sync_targets(changes, ['me'], 100, 103)
26
+ self.assertEqual(targets, [101, 102, 103])
27
+
28
+ def test_two_own_changelists(self):
29
+ changes = _changes((100, 'other'), (101, 'other'), (102, 'me'),
30
+ (103, 'other'), (104, 'other'), (105, 'me'),
31
+ (106, 'other'))
32
+ targets = build_sync_targets(changes, ['me'], 100, 106)
33
+ self.assertEqual(targets, [101, 102, 104, 105, 106])
34
+
35
+ def test_back_to_back_own_changelists(self):
36
+ """A predecessor that is itself one of the user's changelists is
37
+ already a target, so it must not be repeated."""
38
+ changes = _changes((100, 'other'), (101, 'other'),
39
+ (102, 'me'), (103, 'me'))
40
+ targets = build_sync_targets(changes, ['me'], 100, 103)
41
+ self.assertEqual(targets, [101, 102, 103])
42
+
43
+ def test_predecessor_is_last_synced(self):
44
+ """The changelist before the user's own submit is already in git,
45
+ so only the user's own submit is synced separately."""
46
+ changes = _changes((100, 'other'), (101, 'me'), (102, 'other'))
47
+ targets = build_sync_targets(changes, ['me'], 100, 102)
48
+ self.assertEqual(targets, [101, 102])
49
+
50
+ def test_own_changelist_is_the_upper_bound(self):
51
+ changes = _changes((100, 'other'), (101, 'other'), (102, 'me'))
52
+ targets = build_sync_targets(changes, ['me'], 100, 102)
53
+ self.assertEqual(targets, [101, 102])
54
+
55
+ def test_no_own_changelists(self):
56
+ changes = _changes((100, 'other'), (101, 'other'), (102, 'other'))
57
+ targets = build_sync_targets(changes, ['me'], 100, 102)
58
+ self.assertEqual(targets, [102])
59
+
60
+ def test_no_changes_at_all(self):
61
+ """Nothing affected the depot root in the range, so the upper bound
62
+ is still synced: it may be a changelist elsewhere in the depot."""
63
+ targets = build_sync_targets([], ['me'], 100, 120)
64
+ self.assertEqual(targets, [120])
65
+
66
+ def test_user_match_is_case_insensitive(self):
67
+ changes = _changes((100, 'other'), (101, 'other'), (102, 'Me'))
68
+ targets = build_sync_targets(changes, ['me'], 100, 103)
69
+ self.assertEqual(targets, [101, 102, 103])
70
+
71
+ def test_two_users_both_split_out(self):
72
+ changes = _changes((100, 'other'), (101, 'other'), (102, 'alice'),
73
+ (103, 'other'), (104, 'other'), (105, 'bob'),
74
+ (106, 'other'))
75
+ targets = build_sync_targets(changes, ['alice', 'bob'], 100, 106)
76
+ self.assertEqual(targets, [101, 102, 104, 105, 106])
77
+
78
+ def test_adjacent_changelists_from_two_selected_users(self):
79
+ """alice's changelist is the predecessor of bob's, so it is already
80
+ a target and must not be repeated."""
81
+ changes = _changes((100, 'other'), (101, 'other'),
82
+ (102, 'alice'), (103, 'bob'))
83
+ targets = build_sync_targets(changes, ['alice', 'bob'], 100, 103)
84
+ self.assertEqual(targets, [101, 102, 103])
85
+
86
+ def test_unselected_user_is_not_split_out(self):
87
+ changes = _changes((100, 'other'), (101, 'other'), (102, 'alice'),
88
+ (103, 'other'), (104, 'bob'))
89
+ targets = build_sync_targets(changes, ['alice'], 100, 104)
90
+ self.assertEqual(targets, [101, 102, 104])
91
+
92
+ def test_targets_are_strictly_increasing(self):
93
+ changes = _changes((100, 'me'), (101, 'me'), (102, 'other'),
94
+ (103, 'me'), (104, 'me'), (105, 'other'))
95
+ targets = build_sync_targets(changes, ['me'], 100, 105)
96
+ self.assertEqual(targets, sorted(set(targets)))
97
+ self.assertEqual(targets, [101, 102, 103, 104, 105])
98
+
99
+
100
+ class TestSyncMineCommand(unittest.TestCase):
101
+
102
+ def setUp(self):
103
+ patcher = mock.patch(
104
+ 'git_p4son.sync_split.resolve_depot_root',
105
+ return_value=ResolvedDepot(depot_root='//myclient',
106
+ client_spec=None))
107
+ patcher.start()
108
+ self.addCleanup(patcher.stop)
109
+
110
+ def _args(self, **overrides):
111
+ values = dict(changelist=None, user=None, dry_run=False,
112
+ workspace_dir='/ws')
113
+ values.update(overrides)
114
+ return mock.Mock(**values)
115
+
116
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
117
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
118
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value='me')
119
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=106)
120
+ @mock.patch('git_p4son.sync_split.git_last_sync',
121
+ return_value=LastSync(changelist=100, commit='abc123'))
122
+ def test_delegates_resolved_sequence_to_sync(
123
+ self, _last_sync, _latest, _user, mock_changes, mock_sync):
124
+ mock_changes.return_value = _changes(
125
+ (100, 'other'), (101, 'other'), (102, 'me'),
126
+ (103, 'other'), (104, 'other'), (105, 'me'), (106, 'other'))
127
+ args = self._args()
128
+ rc = sync_split_command(args)
129
+ self.assertEqual(rc, 0)
130
+ mock_sync.assert_called_once_with(args)
131
+ self.assertEqual(args.changelist, ['101', '102', '104', '105', '106'])
132
+ self.assertFalse(args.force)
133
+
134
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
135
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
136
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value='me')
137
+ @mock.patch('git_p4son.sync_split.get_latest_changelist')
138
+ @mock.patch('git_p4son.sync_split.git_last_sync',
139
+ return_value=LastSync(changelist=100, commit='abc123'))
140
+ def test_explicit_upper_bound_skips_head_lookup(
141
+ self, _last_sync, mock_latest, _user, mock_changes, mock_sync):
142
+ mock_changes.return_value = _changes(
143
+ (100, 'other'), (101, 'other'), (102, 'me'))
144
+ args = self._args(changelist='103')
145
+ rc = sync_split_command(args)
146
+ self.assertEqual(rc, 0)
147
+ mock_latest.assert_not_called()
148
+ mock_changes.assert_called_once_with('//myclient', 100, 103, '/ws')
149
+ self.assertEqual(args.changelist, ['101', '102', '103'])
150
+
151
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
152
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
153
+ @mock.patch('git_p4son.sync_split.get_p4_user')
154
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
155
+ @mock.patch('git_p4son.sync_split.git_last_sync',
156
+ return_value=LastSync(changelist=100, commit='abc123'))
157
+ def test_user_option_overrides_p4_user(
158
+ self, _last_sync, _latest, mock_p4_user, mock_changes, _sync):
159
+ mock_changes.return_value = _changes(
160
+ (100, 'other'), (101, 'other'), (102, 'colleague'))
161
+ args = self._args(user=['colleague'])
162
+ rc = sync_split_command(args)
163
+ self.assertEqual(rc, 0)
164
+ mock_p4_user.assert_not_called()
165
+ self.assertEqual(args.changelist, ['101', '102', '103'])
166
+
167
+ @mock.patch('git_p4son.sync_split.sync_command')
168
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
169
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value='me')
170
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
171
+ @mock.patch('git_p4son.sync_split.git_last_sync',
172
+ return_value=LastSync(changelist=100, commit='abc123'))
173
+ def test_dry_run_does_not_sync(
174
+ self, _last_sync, _latest, _user, mock_changes, mock_sync):
175
+ mock_changes.return_value = _changes(
176
+ (100, 'other'), (101, 'other'), (102, 'me'))
177
+ rc = sync_split_command(self._args(dry_run=True))
178
+ self.assertEqual(rc, 0)
179
+ mock_sync.assert_not_called()
180
+
181
+ @mock.patch('git_p4son.sync_split.git_last_sync', return_value=None)
182
+ def test_no_previous_sync_aborts(self, _last_sync):
183
+ """Without a synced changelist there is no lower bound for the scan."""
184
+ rc = sync_split_command(self._args())
185
+ self.assertEqual(rc, 1)
186
+
187
+ @mock.patch('git_p4son.sync_split.sync_command')
188
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=100)
189
+ @mock.patch('git_p4son.sync_split.git_last_sync',
190
+ return_value=LastSync(changelist=100, commit='abc123'))
191
+ def test_already_at_target_is_a_no_op(
192
+ self, _last_sync, _latest, mock_sync):
193
+ rc = sync_split_command(self._args())
194
+ self.assertEqual(rc, 0)
195
+ mock_sync.assert_not_called()
196
+
197
+ @mock.patch('git_p4son.sync_split.sync_command')
198
+ @mock.patch('git_p4son.sync_split.git_last_sync',
199
+ return_value=LastSync(changelist=100, commit='abc123'))
200
+ def test_older_target_aborts(self, _last_sync, mock_sync):
201
+ rc = sync_split_command(self._args(changelist='90'))
202
+ self.assertEqual(rc, 1)
203
+ mock_sync.assert_not_called()
204
+
205
+ @mock.patch('git_p4son.sync_split.git_last_sync',
206
+ return_value=LastSync(changelist=100, commit='abc123'))
207
+ def test_invalid_changelist_aborts(self, _last_sync):
208
+ rc = sync_split_command(self._args(changelist='not-a-number'))
209
+ self.assertEqual(rc, 1)
210
+
211
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value=None)
212
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
213
+ @mock.patch('git_p4son.sync_split.git_last_sync',
214
+ return_value=LastSync(changelist=100, commit='abc123'))
215
+ def test_unknown_p4_user_aborts(self, _last_sync, _latest, _user):
216
+ rc = sync_split_command(self._args())
217
+ self.assertEqual(rc, 1)
218
+
219
+ @mock.patch('git_p4son.sync_split.resolve_depot_root', return_value=None)
220
+ def test_unresolved_depot_root_aborts(self, _resolved):
221
+ rc = sync_split_command(self._args())
222
+ self.assertEqual(rc, 1)
223
+
224
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
225
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
226
+ @mock.patch('git_p4son.sync_split.get_p4_user')
227
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=106)
228
+ @mock.patch('git_p4son.sync_split.git_last_sync',
229
+ return_value=LastSync(changelist=100, commit='abc123'))
230
+ def test_multiple_users_are_all_split_out(
231
+ self, _last_sync, _latest, mock_p4_user, mock_changes, _sync):
232
+ mock_changes.return_value = _changes(
233
+ (100, 'other'), (101, 'other'), (102, 'alice'),
234
+ (103, 'other'), (104, 'other'), (105, 'bob'), (106, 'other'))
235
+ args = self._args(user=['alice', 'bob'])
236
+ rc = sync_split_command(args)
237
+ self.assertEqual(rc, 0)
238
+ mock_p4_user.assert_not_called()
239
+ self.assertEqual(args.changelist, ['101', '102', '104', '105', '106'])
240
+
241
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
242
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
243
+ @mock.patch('git_p4son.sync_split.get_p4_user')
244
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
245
+ @mock.patch('git_p4son.sync_split.git_last_sync',
246
+ return_value=LastSync(changelist=100, commit='abc123'))
247
+ def test_repeated_user_names_are_collapsed(
248
+ self, _last_sync, _latest, _p4_user, mock_changes, _sync):
249
+ """Naming the same user twice (in any casing) must not change the
250
+ resolved sequence."""
251
+ mock_changes.return_value = _changes(
252
+ (100, 'other'), (101, 'other'), (102, 'alice'))
253
+ args = self._args(user=['alice', 'Alice'])
254
+ rc = sync_split_command(args)
255
+ self.assertEqual(rc, 0)
256
+ self.assertEqual(args.changelist, ['101', '102', '103'])
257
+
258
+
259
+ if __name__ == '__main__':
260
+ unittest.main()
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
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