git-p4son 0.3.0__tar.gz → 0.3.1__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 (52) hide show
  1. {git_p4son-0.3.0 → git_p4son-0.3.1}/PKG-INFO +27 -4
  2. {git_p4son-0.3.0 → git_p4son-0.3.1}/README.md +26 -3
  3. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/changelist_store.py +1 -1
  5. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/cli.py +11 -4
  6. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/complete.py +10 -5
  7. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/config.py +11 -0
  8. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/init.py +31 -17
  9. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/sync.py +144 -65
  10. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son.egg-info/PKG-INFO +27 -4
  11. {git_p4son-0.3.0 → git_p4son-0.3.1}/pyproject.toml +1 -1
  12. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_changelist_store.py +5 -0
  13. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_cli.py +7 -3
  14. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_complete.py +15 -0
  15. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_config.py +22 -1
  16. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_init.py +37 -5
  17. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_sync.py +382 -14
  18. {git_p4son-0.3.0 → git_p4son-0.3.1}/LICENSE +0 -0
  19. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/__main__.py +0 -0
  20. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/alias.py +0 -0
  21. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/common.py +0 -0
  22. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/completions/_git-p4son +0 -0
  23. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/completions/git-p4son.bash +0 -0
  24. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/completions/git-p4son.ps1 +0 -0
  25. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/git.py +0 -0
  26. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/hooks.py +0 -0
  27. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/lib.py +0 -0
  28. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/list_changes.py +0 -0
  29. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/log.py +0 -0
  30. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/new.py +0 -0
  31. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/perforce.py +0 -0
  32. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/review.py +0 -0
  33. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/state.py +0 -0
  34. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son/update.py +0 -0
  35. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son.egg-info/SOURCES.txt +0 -0
  36. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son.egg-info/dependency_links.txt +0 -0
  37. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son.egg-info/entry_points.txt +0 -0
  38. {git_p4son-0.3.0 → git_p4son-0.3.1}/git_p4son.egg-info/top_level.txt +0 -0
  39. {git_p4son-0.3.0 → git_p4son-0.3.1}/setup.cfg +0 -0
  40. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_alias.py +0 -0
  41. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_common.py +0 -0
  42. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_git.py +0 -0
  43. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_hooks.py +0 -0
  44. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_lib_changelist.py +0 -0
  45. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_lib_edit.py +0 -0
  46. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_lib_review.py +0 -0
  47. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_list_changes.py +0 -0
  48. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_log.py +0 -0
  49. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_new.py +0 -0
  50. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_perforce.py +0 -0
  51. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_review.py +0 -0
  52. {git_p4son-0.3.0 → git_p4son-0.3.1}/tests/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -266,6 +266,11 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
266
266
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
267
267
  `.git-p4son/config.toml` and used by all subsequent commands.
268
268
 
269
+ The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
270
+ `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
271
+ the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
272
+ by hand-editing `config.toml`; both forms work.
273
+
269
274
  The `.gitignore` is set up using this priority:
270
275
  - If `.gitignore` already exists, it is left as is
271
276
  - If `.p4ignore` exists, it is copied to `.gitignore` as a starting point
@@ -276,12 +281,14 @@ The `.gitignore` is set up using this priority:
276
281
  Sync local git repository with a Perforce workspace:
277
282
 
278
283
  ```sh
279
- git p4son sync [changelist] [--force]
284
+ git p4son sync [changelist ...] [--force]
280
285
  ```
281
286
 
282
287
  **Arguments:**
283
- - `changelist` (optional): Changelist number, or `last-synced` to re-sync the last synced changelist. Omit to
284
- sync to the latest changelist affecting the workspace.
288
+ - `changelist` (optional): Changelist number, `last-synced` to re-sync the last synced changelist, or `head`
289
+ to sync to the latest changelist. Omit to sync to the latest changelist affecting the workspace. Several
290
+ strictly increasing changelist numbers may be given to sync each in sequence, producing one commit per
291
+ changelist. A trailing `head` may close out such a sequence to finish at the latest changelist.
285
292
 
286
293
  **Options:**
287
294
  - `-f, --force`: Allow syncing to changelists older than the current one.
@@ -289,11 +296,20 @@ git p4son sync [changelist] [--force]
289
296
  **Examples:**
290
297
  ```sh
291
298
  git p4son sync # sync to latest
299
+ git p4son sync head # sync to latest (explicit)
292
300
  git p4son sync 12345
301
+ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
302
+ git p4son sync 123 156 head # sync 123, 156, then the latest changelist
293
303
  git p4son sync last-synced
294
304
  git p4son sync 12345 --force
295
305
  ```
296
306
 
307
+ #### pre-sync hook
308
+
309
+ Before `git-p4son sync` syncs any changelist, git-p4son runs [hooks](#hooks) from
310
+ `.git-p4son/hooks/pre-sync/`. If any hook exits non-zero the sync is aborted. When a sequence of
311
+ changelists is given, the pre-sync hooks run once, before the first sync.
312
+
297
313
  #### post-sync hook
298
314
 
299
315
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs [hooks](#hooks)
@@ -553,6 +569,13 @@ On Windows, `.ps1`, `.nu`, `.sh`, and `.py` files are run with `powershell.exe`,
553
569
 
554
570
  Non-executable or unrecognized hook files are skipped with a warning.
555
571
 
572
+ ### pre-sync hook
573
+
574
+ Before `git-p4son sync` syncs any changelist, git-p4son runs executable hooks from
575
+ `.git-p4son/hooks/pre-sync/`. If any hook exits with a non-zero code the sync is aborted before any
576
+ changelist is synced. When a sequence of changelists is synced, the pre-sync hooks run only once,
577
+ before the first sync.
578
+
556
579
  ### post-sync hook
557
580
 
558
581
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs executable hooks from
@@ -243,6 +243,11 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
243
243
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
244
244
  `.git-p4son/config.toml` and used by all subsequent commands.
245
245
 
246
+ The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
247
+ `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
248
+ the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
249
+ by hand-editing `config.toml`; both forms work.
250
+
246
251
  The `.gitignore` is set up using this priority:
247
252
  - If `.gitignore` already exists, it is left as is
248
253
  - If `.p4ignore` exists, it is copied to `.gitignore` as a starting point
@@ -253,12 +258,14 @@ The `.gitignore` is set up using this priority:
253
258
  Sync local git repository with a Perforce workspace:
254
259
 
255
260
  ```sh
256
- git p4son sync [changelist] [--force]
261
+ git p4son sync [changelist ...] [--force]
257
262
  ```
258
263
 
259
264
  **Arguments:**
260
- - `changelist` (optional): Changelist number, or `last-synced` to re-sync the last synced changelist. Omit to
261
- sync to the latest changelist affecting the workspace.
265
+ - `changelist` (optional): Changelist number, `last-synced` to re-sync the last synced changelist, or `head`
266
+ to sync to the latest changelist. Omit to sync to the latest changelist affecting the workspace. Several
267
+ strictly increasing changelist numbers may be given to sync each in sequence, producing one commit per
268
+ changelist. A trailing `head` may close out such a sequence to finish at the latest changelist.
262
269
 
263
270
  **Options:**
264
271
  - `-f, --force`: Allow syncing to changelists older than the current one.
@@ -266,11 +273,20 @@ git p4son sync [changelist] [--force]
266
273
  **Examples:**
267
274
  ```sh
268
275
  git p4son sync # sync to latest
276
+ git p4son sync head # sync to latest (explicit)
269
277
  git p4son sync 12345
278
+ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
279
+ git p4son sync 123 156 head # sync 123, 156, then the latest changelist
270
280
  git p4son sync last-synced
271
281
  git p4son sync 12345 --force
272
282
  ```
273
283
 
284
+ #### pre-sync hook
285
+
286
+ Before `git-p4son sync` syncs any changelist, git-p4son runs [hooks](#hooks) from
287
+ `.git-p4son/hooks/pre-sync/`. If any hook exits non-zero the sync is aborted. When a sequence of
288
+ changelists is given, the pre-sync hooks run once, before the first sync.
289
+
274
290
  #### post-sync hook
275
291
 
276
292
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs [hooks](#hooks)
@@ -530,6 +546,13 @@ On Windows, `.ps1`, `.nu`, `.sh`, and `.py` files are run with `powershell.exe`,
530
546
 
531
547
  Non-executable or unrecognized hook files are skipped with a warning.
532
548
 
549
+ ### pre-sync hook
550
+
551
+ Before `git-p4son sync` syncs any changelist, git-p4son runs executable hooks from
552
+ `.git-p4son/hooks/pre-sync/`. If any hook exits with a non-zero code the sync is aborted before any
553
+ changelist is synced. When a sequence of changelists is synced, the pre-sync hooks run only once,
554
+ before the first sync.
555
+
533
556
  ### post-sync hook
534
557
 
535
558
  After a successful `git-p4son sync` that actually performs sync work, git-p4son runs executable hooks from
@@ -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.0"
10
+ __version__ = "0.3.1"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -11,7 +11,7 @@ from . import CONFIG_DIR
11
11
  from .log import log
12
12
 
13
13
 
14
- RESERVED_KEYWORDS = frozenset({'last-synced', 'branch'})
14
+ RESERVED_KEYWORDS = frozenset({'last-synced', 'head', 'branch'})
15
15
 
16
16
  # Allowed characters: ASCII letters, digits, hyphen, underscore, dot.
17
17
  # Must not start or end with a dot, so "." and ".." are rejected and the
@@ -31,7 +31,10 @@ def create_parser() -> argparse.ArgumentParser:
31
31
  epilog="""
32
32
  Examples:
33
33
  git-p4son sync # Sync to the latest changelist
34
+ git-p4son sync head # Sync to the latest changelist (explicit)
34
35
  git-p4son sync 12345 # Sync to changelist 12345
36
+ git-p4son sync 123 156 178 # Sync each changelist in sequence, one commit each
37
+ git-p4son sync 123 156 head # Sync 123, 156, then the latest changelist
35
38
  git-p4son sync last-synced # Re-sync the last synced changelist
36
39
  git-p4son new -m "Fix bug" # Create changelist, alias defaults to branch name
37
40
  git-p4son new -m "Fix bug" --review # Create changelist, create Swarm review
@@ -78,10 +81,14 @@ Examples:
78
81
  )
79
82
  sync_parser.add_argument(
80
83
  'changelist',
81
- nargs='?',
82
- default=None,
83
- help='Changelist number to sync, or "last-synced" to re-sync the last synced changelist. '
84
- 'Omit to sync to the latest changelist affecting the workspace'
84
+ nargs='*',
85
+ default=[],
86
+ metavar='CHANGELIST',
87
+ help='Changelist number(s) to sync, "last-synced" to re-sync the last synced changelist, '
88
+ 'or "head" to sync to the latest changelist. Give several strictly increasing '
89
+ 'changelist numbers to sync each in sequence, one commit per changelist; "head" may '
90
+ 'close out such a sequence as a trailing target. Omit to sync to the latest '
91
+ 'changelist affecting the workspace'
85
92
  )
86
93
  sync_parser.add_argument(
87
94
  '-f', '--force',
@@ -106,11 +106,16 @@ def _complete_positional(command, subcommand, positional_count,
106
106
  """Complete a positional argument."""
107
107
  aliases = _get_alias_names(workspace_dir)
108
108
 
109
- if command == 'sync' and positional_count == 0:
110
- candidates = [
111
- ('last-synced', 'Re-sync last synced changelist'),
112
- ]
113
- return _filter(candidates, prefix)
109
+ if command == 'sync':
110
+ if positional_count == 0:
111
+ candidates = [
112
+ ('head', 'Sync to the latest changelist'),
113
+ ('last-synced', 'Re-sync last synced changelist'),
114
+ ]
115
+ return _filter(candidates, prefix)
116
+ # "head" may also close out a sequence of changelist numbers.
117
+ return _filter(
118
+ [('head', 'Sync to the latest changelist')], prefix)
114
119
 
115
120
  if command == 'update' and positional_count == 0:
116
121
  branch_candidates = _get_branch_candidates(prefix, workspace_dir)
@@ -12,6 +12,12 @@ from . import CONFIG_DIR
12
12
 
13
13
  _BARE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
14
14
 
15
+ # Placeholder allowed in a stored depot root, substituted with the live
16
+ # Perforce client (workspace) name each time the root is used. Storing e.g.
17
+ # root = "//$(workspace)/Engine" keeps the config working after the workspace
18
+ # is renamed, at the cost of one client-name lookup per command.
19
+ WORKSPACE_PLACEHOLDER = '$(workspace)'
20
+
15
21
 
16
22
  def config_path(workspace_dir: str) -> str:
17
23
  """Return the path to the config file."""
@@ -94,3 +100,8 @@ def get_depot_root(workspace_dir: str) -> str | None:
94
100
  """Get the depot root from config, or None if not configured."""
95
101
  config = load_config(workspace_dir)
96
102
  return config.get('depot', {}).get('root')
103
+
104
+
105
+ def expand_depot_root(depot_root: str, workspace_name: str) -> str:
106
+ """Substitute the live workspace name for the $(workspace) placeholder."""
107
+ return depot_root.replace(WORKSPACE_PLACEHOLDER, workspace_name)
@@ -10,7 +10,12 @@ import os
10
10
  import shutil
11
11
 
12
12
  from .common import CommandError, run, run_with_output
13
- from .config import get_depot_root, save_config
13
+ from .config import (
14
+ WORKSPACE_PLACEHOLDER,
15
+ expand_depot_root,
16
+ get_depot_root,
17
+ save_config,
18
+ )
14
19
  from .log import log
15
20
  from .perforce import get_client_spec
16
21
  from .git import resolve_editor
@@ -26,31 +31,38 @@ def _validate_depot_root(depot_root: str, cwd: str) -> bool:
26
31
  return False
27
32
 
28
33
 
29
- def _compute_cwd_depot_root(client_name: str, cwd: str,
30
- p4_workspace_root: str) -> str | None:
31
- """Compute depot root for cwd relative to workspace root.
34
+ def _compute_cwd_depot_root(cwd: str, p4_workspace_root: str) -> str | None:
35
+ """Compute the depot root template for cwd relative to workspace root.
32
36
 
33
- Returns None if cwd is the workspace root (identical to entire workspace).
37
+ Uses the $(workspace) placeholder in place of the client name so the stored
38
+ root survives a workspace rename. Returns None if cwd is the workspace root
39
+ (identical to the entire workspace).
34
40
  """
35
41
  rel = os.path.relpath(cwd, p4_workspace_root)
36
42
  if rel == '.':
37
43
  return None
38
44
  rel_posix = rel.replace(os.sep, '/')
39
- return f'//{client_name}/{rel_posix}'
45
+ return f'//{WORKSPACE_PLACEHOLDER}/{rel_posix}'
40
46
 
41
47
 
42
48
  def _select_depot_root(client_name: str, cwd: str,
43
49
  p4_workspace_root: str) -> str | None:
44
- """Interactive prompt for depot root selection. Returns root or None to abort."""
45
- entire_root = f'//{client_name}'
46
- cwd_root = _compute_cwd_depot_root(client_name, cwd, p4_workspace_root)
50
+ """Interactive prompt for depot root selection.
51
+
52
+ Returns a depot root template (with the $(workspace) placeholder) to store,
53
+ or None to abort. Menu entries and validation use the resolved path.
54
+ """
55
+ entire_root = f'//{WORKSPACE_PLACEHOLDER}'
56
+ cwd_root = _compute_cwd_depot_root(cwd, p4_workspace_root)
47
57
 
48
58
  while True:
49
59
  print()
50
60
  print('Select the depot path that git-p4son should sync:')
51
- print(f' 1. Entire workspace: {entire_root}/...')
61
+ print(f' 1. Entire workspace: '
62
+ f'{expand_depot_root(entire_root, client_name)}/...')
52
63
  if cwd_root:
53
- print(f' 2. Current directory: {cwd_root}/...')
64
+ print(f' 2. Current directory: '
65
+ f'{expand_depot_root(cwd_root, client_name)}/...')
54
66
  abort_num = '3'
55
67
  else:
56
68
  abort_num = '2'
@@ -72,10 +84,11 @@ def _select_depot_root(client_name: str, cwd: str,
72
84
  print(f'Invalid choice: {choice}')
73
85
  continue
74
86
 
75
- if _validate_depot_root(depot_root, cwd):
87
+ resolved = expand_depot_root(depot_root, client_name)
88
+ if _validate_depot_root(resolved, cwd):
76
89
  return depot_root
77
90
 
78
- log.error(f'Depot root {depot_root}/... is not valid')
91
+ log.error(f'Depot root {resolved}/... is not valid')
79
92
 
80
93
 
81
94
  def _configure_depot_root(client_name: str, cwd: str,
@@ -84,17 +97,18 @@ def _configure_depot_root(client_name: str, cwd: str,
84
97
  log.heading('Finding git-p4son depot root')
85
98
  depot_root = get_depot_root(cwd)
86
99
  if depot_root:
87
- log.success(f'{depot_root}/...')
100
+ log.success(f'{expand_depot_root(depot_root, client_name)}/...')
88
101
  else:
89
102
  log.warning('no root configured')
90
103
 
91
104
  if depot_root:
92
105
  log.heading('Validating depot root')
93
- if _validate_depot_root(depot_root, cwd):
106
+ resolved = expand_depot_root(depot_root, client_name)
107
+ if _validate_depot_root(resolved, cwd):
94
108
  log.success('all good')
95
109
  return True
96
110
  else:
97
- log.error(f'{depot_root}/... is not valid')
111
+ log.error(f'{resolved}/... is not valid')
98
112
  depot_root = None
99
113
 
100
114
  if not depot_root:
@@ -104,7 +118,7 @@ def _configure_depot_root(client_name: str, cwd: str,
104
118
  log.error('aborting')
105
119
  return False
106
120
 
107
- log.success(f'{depot_root}/...')
121
+ log.success(f'{expand_depot_root(depot_root, client_name)}/...')
108
122
  save_config(cwd, {'depot': {'root': depot_root}})
109
123
  return True
110
124
 
@@ -13,7 +13,7 @@ from dataclasses import dataclass, field
13
13
  from typing import IO
14
14
 
15
15
  from .common import RunError, prompt_choice, run_with_output
16
- from .config import get_depot_root
16
+ from .config import WORKSPACE_PLACEHOLDER, expand_depot_root, get_depot_root
17
17
  from .state import dismiss_clobber_warning, is_clobber_warning_dismissed
18
18
  from .git import (
19
19
  add_all_files, commit, find_base_commits, get_blob_oids,
@@ -523,6 +523,37 @@ def _handle_clobber_warning(clobber: bool, workspace_dir: str) -> bool:
523
523
  return True
524
524
 
525
525
 
526
+ def _run_pre_sync_hooks(workspace_dir: str, invocation_dir: str) -> bool:
527
+ """Run pre-sync hooks; return False when any hook fails, aborting the sync.
528
+
529
+ Runs once before any changelist is synced. All hooks run (they are
530
+ independent), then the sync is aborted if any returned a non-zero code.
531
+ """
532
+ results = run_hooks('pre-sync', workspace_dir, invocation_dir)
533
+ if any(result.returncode != 0 for result in results):
534
+ log.error('Aborting sync because a pre-sync hook failed')
535
+ return False
536
+ return True
537
+
538
+
539
+ def _sync_pass(changelist: int, label: str, depot_root: str,
540
+ workspace_dir: str, pre_sync_head_commit: str, temp_root: str,
541
+ uses_crlf: bool, clobber: bool) -> WritableSyncFileSet:
542
+ """Run one sync pass: preview, prepare writable files, and p4 sync.
543
+
544
+ Returns the classified writable file set. p4 sync is skipped when the
545
+ preview is empty (nothing to sync).
546
+ """
547
+ preview = p4_sync_preview(changelist, depot_root, workspace_dir)
548
+ prep = prepare_writable_files(preview, workspace_dir, pre_sync_head_commit,
549
+ temp_root, uses_crlf=uses_crlf,
550
+ clobber=clobber)
551
+ if preview:
552
+ p4_sync(changelist, label, depot_root, workspace_dir,
553
+ expected_clobber=set(prep.ignored))
554
+ return prep
555
+
556
+
526
557
  def sync_command(args: argparse.Namespace) -> int:
527
558
  """Execute the sync command."""
528
559
  workspace_dir = args.workspace_dir
@@ -533,6 +564,17 @@ def sync_command(args: argparse.Namespace) -> int:
533
564
  if not depot_root:
534
565
  log.error('No depot root configured. Run "git p4son init" first.')
535
566
  return 1
567
+
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
+ client_spec = get_client_spec(workspace_dir)
572
+ if WORKSPACE_PLACEHOLDER in depot_root and not client_spec:
573
+ log.error('Cannot resolve $(workspace) in depot root: not inside a '
574
+ 'Perforce workspace')
575
+ return 1
576
+ if client_spec:
577
+ depot_root = expand_depot_root(depot_root, client_spec.name)
536
578
  log.success(depot_root)
537
579
 
538
580
  log.heading('Checking git workspace')
@@ -578,7 +620,7 @@ def sync_command(args: argparse.Namespace) -> int:
578
620
  # the post-sync merge doesn't conflict on LF-vs-CRLF differences alone.
579
621
  # The clobber option changes whether git-ignored writable files survive
580
622
  # the sync, which the prepare summary needs to report accurately.
581
- client_spec = get_client_spec(workspace_dir)
623
+ # (client_spec was fetched above to resolve the depot root.)
582
624
  uses_crlf = bool(client_spec and client_spec.uses_crlf)
583
625
  clobber = bool(client_spec and client_spec.clobber)
584
626
 
@@ -588,94 +630,131 @@ def sync_command(args: argparse.Namespace) -> int:
588
630
  # Temp root for staging HEAD/baseline file content between classification
589
631
  # and the post-sync merge. Cleaned up automatically on exit.
590
632
  with tempfile.TemporaryDirectory(prefix='git-p4son-sync-') as temp_root:
591
- if args.changelist is not None \
592
- and args.changelist.lower() == 'last-synced':
633
+ raw = args.changelist
634
+ lowered = [c.lower() for c in raw]
635
+
636
+ # "last-synced" is a keyword that only makes sense on its own.
637
+ if 'last-synced' in lowered:
638
+ if lowered != ['last-synced']:
639
+ log.error('The "last-synced" keyword cannot be combined with '
640
+ 'other changelists')
641
+ return 1
593
642
  if not last_sync:
594
643
  log.error('No previous sync found, cannot use "last-synced"')
595
644
  return 1
596
- preview = p4_sync_preview(
597
- last_sync.changelist, depot_root, workspace_dir)
598
- if preview:
599
- prep = prepare_writable_files(preview, workspace_dir,
600
- pre_sync_head_commit, temp_root,
601
- uses_crlf=uses_crlf, clobber=clobber)
602
- p4_sync(last_sync.changelist, last_changelist_label,
603
- depot_root, workspace_dir,
604
- expected_clobber=set(prep.ignored))
645
+ if not _run_pre_sync_hooks(workspace_dir, invocation_dir):
646
+ return 1
647
+ _sync_pass(last_sync.changelist, last_changelist_label, depot_root,
648
+ workspace_dir, pre_sync_head_commit, temp_root,
649
+ uses_crlf, clobber)
605
650
  run_hooks('post-sync', workspace_dir, invocation_dir)
606
651
  return 0
607
652
 
608
- # No argument means sync to latest
609
- if args.changelist is None:
653
+ # "head" resolves to the latest changelist, the largest in an
654
+ # increasing sequence, so it may only appear as the final target.
655
+ if 'head' in lowered and lowered.index('head') != len(lowered) - 1:
656
+ log.error('The "head" keyword must come last')
657
+ return 1
658
+
659
+ # Resolve the ordered list of (changelist, label) targets to sync,
660
+ # replacing a trailing "head" with the concrete latest changelist so
661
+ # the increasing-order check below compares real numbers.
662
+ if not raw:
610
663
  log.heading('Finding latest changelist')
611
- changelist = get_latest_changelist(depot_root, workspace_dir)
612
- log.success(f'CL {changelist}')
613
- changelist_label = 'latest'
664
+ latest = get_latest_changelist(depot_root, workspace_dir)
665
+ log.success(f'CL {latest}')
666
+ targets = [(latest, 'latest')]
614
667
  else:
615
- changelist_label = 'specified'
616
- try:
617
- changelist = int(args.changelist)
618
- except ValueError:
619
- log.error(f'Invalid changelist number: {args.changelist}')
668
+ targets = []
669
+ for c in raw:
670
+ if c.lower() == 'head':
671
+ log.heading('Finding latest changelist')
672
+ latest = get_latest_changelist(depot_root, workspace_dir)
673
+ log.success(f'CL {latest}')
674
+ targets.append((latest, 'latest'))
675
+ else:
676
+ try:
677
+ targets.append((int(c), 'specified'))
678
+ except ValueError:
679
+ log.error(f'Invalid changelist number: {c}')
680
+ return 1
681
+
682
+ # Targets are synced in the given order, so they must be strictly
683
+ # increasing: no syncing back and forth.
684
+ numbers = [cl for cl, _ in targets]
685
+ for prev, curr in zip(numbers, numbers[1:]):
686
+ if curr <= prev:
687
+ log.error('Changelists must be strictly increasing, '
688
+ f'got {prev} then {curr}')
620
689
  return 1
621
690
 
622
691
  last_changelist = last_sync.changelist if last_sync else None
623
- if last_changelist == changelist:
624
- log.info(f'Already at CL {last_changelist}, nothing to do.')
625
- log.heading('Skipping post-sync hooks')
626
- return 0
627
692
 
628
- # Check if trying to sync to an older changelist
629
- if last_sync and changelist < last_sync.changelist:
693
+ # Syncing to a changelist older than the current one needs --force.
694
+ # Targets are strictly increasing, so the smallest is numbers[0]; an
695
+ # equal-to-current changelist (dropped just below) is not "older".
696
+ if last_sync and numbers[0] < last_sync.changelist:
630
697
  if not args.force:
631
698
  log.error(
632
- f'Cannot sync to CL {changelist} '
699
+ f'Cannot sync to CL {numbers[0]} '
633
700
  f'(currently at CL {last_sync.changelist}) without --force.')
634
701
  return 1
635
- else:
636
- log.warning(
637
- f'Syncing to older CL {changelist} '
638
- f'(currently at CL {last_sync.changelist}) with --force')
702
+ log.warning(
703
+ f'Syncing to older CL {numbers[0]} '
704
+ f'(currently at CL {last_sync.changelist}) with --force')
705
+
706
+ # Drop a target equal to the last synced changelist: we are already
707
+ # there, so there is nothing to sync or commit for it. An explicitly
708
+ # requested *older* changelist is kept and synced as the user asked.
709
+ if last_changelist is not None and last_changelist in numbers:
710
+ log.info(f'Skipping CL {last_changelist} (already synced)')
711
+ targets = [(cl, lbl) for cl, lbl in targets
712
+ if cl != last_changelist]
713
+
714
+ if not targets:
715
+ log.info('Already synced, nothing to do.')
716
+ log.heading('Skipping post-sync hooks')
717
+ return 0
718
+
719
+ # Pre-sync hooks run once, before syncing any changelist (including the
720
+ # catch-up pass), and abort the whole sync if any of them fail.
721
+ if not _run_pre_sync_hooks(workspace_dir, invocation_dir):
722
+ return 1
639
723
 
640
- # First sync pass: to last_synced_cl
641
724
  all_changed: list[ChangedFile] = []
642
725
  all_ignored: list[str] = []
643
726
 
727
+ # Catch-up pass to the last synced changelist. This makes locally
728
+ # modified (writable) files read-only and stages their content so the
729
+ # post-sync merge can restore local changes; its result is folded into
730
+ # the first target commit.
644
731
  if last_changelist is not None:
645
- preview = p4_sync_preview(
646
- last_changelist, depot_root, workspace_dir)
647
- prep = prepare_writable_files(preview, workspace_dir,
648
- pre_sync_head_commit, temp_root,
649
- uses_crlf=uses_crlf, clobber=clobber)
732
+ prep = _sync_pass(last_changelist, last_changelist_label,
733
+ depot_root, workspace_dir, pre_sync_head_commit,
734
+ temp_root, uses_crlf, clobber)
650
735
  all_changed.extend(prep.changed)
651
736
  all_ignored.extend(prep.ignored)
652
- if preview:
653
- p4_sync(last_changelist, last_changelist_label, depot_root,
654
- workspace_dir, expected_clobber=set(prep.ignored))
655
-
656
- # Second sync pass: to target CL
657
- preview = p4_sync_preview(changelist, depot_root, workspace_dir)
658
- prep = prepare_writable_files(preview, workspace_dir,
659
- pre_sync_head_commit, temp_root,
660
- uses_crlf=uses_crlf, clobber=clobber)
661
- all_changed.extend(prep.changed)
662
- all_ignored.extend(prep.ignored)
663
- if preview:
664
- p4_sync(changelist, changelist_label, depot_root,
665
- workspace_dir, expected_clobber=set(prep.ignored))
666
-
667
- # Commit (pure Perforce state)
668
- log.heading('Committing git changes')
669
- dirty_files = get_dirty_files(workspace_dir)
670
- if dirty_files:
671
- add_all_files(workspace_dir)
672
-
673
- commit_msg = f'git-p4son: p4 sync {depot_root}/...@{changelist}'
674
- commit(commit_msg, workspace_dir, allow_empty=True)
675
- log.success(f'Committed {len(dirty_files)} files')
737
+
738
+ # Sync each target changelist in turn, committing pure Perforce state
739
+ # for each. Local changes are merged back once at the very end so the
740
+ # intermediate commits stay clean.
741
+ for changelist, changelist_label in targets:
742
+ prep = _sync_pass(changelist, changelist_label, depot_root,
743
+ workspace_dir, pre_sync_head_commit, temp_root,
744
+ uses_crlf, clobber)
745
+ all_changed.extend(prep.changed)
746
+ all_ignored.extend(prep.ignored)
747
+
748
+ log.heading(f'Committing git changes for CL {changelist}')
749
+ dirty_files = get_dirty_files(workspace_dir)
750
+ if dirty_files:
751
+ add_all_files(workspace_dir)
752
+ commit_msg = f'git-p4son: p4 sync {depot_root}/...@{changelist}'
753
+ commit(commit_msg, workspace_dir, allow_empty=True)
754
+ log.success(f'Committed {len(dirty_files)} files')
676
755
 
677
756
  # Post-commit: merge changed files back. Dedup by filepath in case
678
- # the same file shows up in both sync passes.
757
+ # the same file shows up in multiple sync passes.
679
758
  by_path: dict[str, ChangedFile] = {}
680
759
  for cf in all_changed:
681
760
  by_path[cf.filepath] = cf