git-p4son 0.4.0__tar.gz → 0.4.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 (62) hide show
  1. {git_p4son-0.4.0 → git_p4son-0.4.1}/PKG-INFO +8 -2
  2. {git_p4son-0.4.0 → git_p4son-0.4.1}/README.md +7 -1
  3. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/changelist_store.py +2 -0
  5. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/cli.py +9 -2
  6. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/common.py +19 -7
  7. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/config.py +22 -0
  8. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/perforce.py +18 -0
  9. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/review.py +2 -0
  10. git_p4son-0.4.1/git_p4son/state.py +38 -0
  11. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/sync.py +11 -0
  12. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/PKG-INFO +8 -2
  13. {git_p4son-0.4.0 → git_p4son-0.4.1}/pyproject.toml +1 -1
  14. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_changelist_store.py +7 -0
  15. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_cli.py +11 -0
  16. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_common.py +19 -0
  17. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_complete.py +2 -0
  18. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_config.py +42 -0
  19. git_p4son-0.4.1/tests/test_state.py +39 -0
  20. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_sync.py +84 -0
  21. git_p4son-0.4.0/git_p4son/state.py +0 -56
  22. git_p4son-0.4.0/tests/test_state.py +0 -64
  23. {git_p4son-0.4.0 → git_p4son-0.4.1}/LICENSE +0 -0
  24. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/__main__.py +0 -0
  25. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/alias.py +0 -0
  26. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/complete.py +0 -0
  27. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/completions/_git-p4son +0 -0
  28. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/completions/git-p4son.bash +0 -0
  29. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/completions/git-p4son.ps1 +0 -0
  30. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/depot.py +0 -0
  31. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/git.py +0 -0
  32. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/hooks.py +0 -0
  33. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/init.py +0 -0
  34. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/lib.py +0 -0
  35. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/list_changes.py +0 -0
  36. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/log.py +0 -0
  37. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/new.py +0 -0
  38. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/sync_split.py +0 -0
  39. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/update.py +0 -0
  40. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/writable.py +0 -0
  41. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/SOURCES.txt +0 -0
  42. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/dependency_links.txt +0 -0
  43. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/entry_points.txt +0 -0
  44. {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/top_level.txt +0 -0
  45. {git_p4son-0.4.0 → git_p4son-0.4.1}/setup.cfg +0 -0
  46. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_alias.py +0 -0
  47. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_depot.py +0 -0
  48. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_git.py +0 -0
  49. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_hooks.py +0 -0
  50. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_init.py +0 -0
  51. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_changelist.py +0 -0
  52. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_edit.py +0 -0
  53. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_revert.py +0 -0
  54. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_review.py +0 -0
  55. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_list_changes.py +0 -0
  56. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_log.py +0 -0
  57. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_new.py +0 -0
  58. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_perforce.py +0 -0
  59. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_review.py +0 -0
  60. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_sync_split.py +0 -0
  61. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_update.py +0 -0
  62. {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_writable.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.4.0
3
+ Version: 0.4.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
@@ -277,6 +277,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
277
277
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
278
278
  `.git-p4son/config.toml` and used by all subsequent commands.
279
279
 
280
+ Nothing in `.git-p4son/` belongs in git. The folder has its own `.gitignore` that ignores everything in it, so there
281
+ is no need to list it in the workspace `.gitignore`.
282
+
280
283
  The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
281
284
  `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
282
285
  the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
@@ -295,7 +298,7 @@ saves the answer; run `git p4son writable apply` once your files are committed t
295
298
  Sync local git repository with a Perforce workspace:
296
299
 
297
300
  ```sh
298
- git p4son sync [changelist ...] [--force]
301
+ git p4son sync [changelist ...] [--force] [--dry-run]
299
302
  ```
300
303
 
301
304
  **Arguments:**
@@ -306,6 +309,8 @@ git p4son sync [changelist ...] [--force]
306
309
 
307
310
  **Options:**
308
311
  - `-f, --force`: Allow syncing to changelists older than the current one.
312
+ - `-n, --dry-run`: Print the resolved sync sequence without syncing. The arguments are validated as for a
313
+ real sync, but nothing else runs: no clean-workspace checks, no hooks, and no prompts.
309
314
 
310
315
  **Examples:**
311
316
  ```sh
@@ -316,6 +321,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
316
321
  git p4son sync 123 156 head # sync 123, 156, then the latest changelist
317
322
  git p4son sync last-synced
318
323
  git p4son sync 12345 --force
324
+ git p4son sync --dry-run # show the changelists a sync to latest would visit
319
325
  ```
320
326
 
321
327
  #### pre-sync hook
@@ -254,6 +254,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
254
254
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
255
255
  `.git-p4son/config.toml` and used by all subsequent commands.
256
256
 
257
+ Nothing in `.git-p4son/` belongs in git. The folder has its own `.gitignore` that ignores everything in it, so there
258
+ is no need to list it in the workspace `.gitignore`.
259
+
257
260
  The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
258
261
  `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
259
262
  the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
@@ -272,7 +275,7 @@ saves the answer; run `git p4son writable apply` once your files are committed t
272
275
  Sync local git repository with a Perforce workspace:
273
276
 
274
277
  ```sh
275
- git p4son sync [changelist ...] [--force]
278
+ git p4son sync [changelist ...] [--force] [--dry-run]
276
279
  ```
277
280
 
278
281
  **Arguments:**
@@ -283,6 +286,8 @@ git p4son sync [changelist ...] [--force]
283
286
 
284
287
  **Options:**
285
288
  - `-f, --force`: Allow syncing to changelists older than the current one.
289
+ - `-n, --dry-run`: Print the resolved sync sequence without syncing. The arguments are validated as for a
290
+ real sync, but nothing else runs: no clean-workspace checks, no hooks, and no prompts.
286
291
 
287
292
  **Examples:**
288
293
  ```sh
@@ -293,6 +298,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
293
298
  git p4son sync 123 156 head # sync 123, 156, then the latest changelist
294
299
  git p4son sync last-synced
295
300
  git p4son sync 12345 --force
301
+ git p4son sync --dry-run # show the changelists a sync to latest would visit
296
302
  ```
297
303
 
298
304
  #### pre-sync hook
@@ -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.4.0"
10
+ __version__ = "0.4.1"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -8,6 +8,7 @@ import os
8
8
  import re
9
9
 
10
10
  from . import CONFIG_DIR
11
+ from .config import ensure_config_dir
11
12
  from .log import log
12
13
 
13
14
 
@@ -85,6 +86,7 @@ def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force:
85
86
  f'Alias "{name}" already exists (use -f/--force to overwrite)')
86
87
  return False
87
88
 
89
+ ensure_config_dir(workspace_dir)
88
90
  if not os.path.isdir(changelists_dir):
89
91
  log.info(f'Creating {changelists_dir}')
90
92
  os.makedirs(changelists_dir, exist_ok=True)
@@ -38,6 +38,7 @@ Examples:
38
38
  git-p4son sync 123 156 178 # Sync each changelist in sequence, one commit each
39
39
  git-p4son sync 123 156 head # Sync 123, 156, then the latest changelist
40
40
  git-p4son sync last-synced # Re-sync the last synced changelist
41
+ git-p4son sync --dry-run # Show the changelists a sync would visit
41
42
  git-p4son sync-split # Sync to latest, your own changelists split out
42
43
  git-p4son sync-split 12345 # Same, but stop at changelist 12345
43
44
  git-p4son sync-split -u alice -u bob # Split out alice's and bob's changelists
@@ -103,6 +104,11 @@ Examples:
103
104
  action='store_true',
104
105
  help='Allow syncing to changelists older than the current one.'
105
106
  )
107
+ sync_parser.add_argument(
108
+ '-n', '--dry-run',
109
+ action='store_true',
110
+ help='Print the resolved sync sequence without syncing'
111
+ )
106
112
 
107
113
  # Sync-split subcommand
108
114
  sync_split_parser = subparsers.add_parser(
@@ -408,10 +414,11 @@ Examples:
408
414
  help='Print the directory instead of the full file path'
409
415
  )
410
416
 
411
- # Hidden _sequence-editor subcommand (used internally by review)
417
+ # Hidden _sequence-editor subcommand (used internally by review). It is
418
+ # given no help: argparse lists a subcommand with help=SUPPRESS anyway,
419
+ # as "==SUPPRESS==", but leaves one without help out of the list.
412
420
  seq_editor_parser = subparsers.add_parser(
413
421
  '_sequence-editor',
414
- help=argparse.SUPPRESS,
415
422
  )
416
423
  seq_editor_parser.add_argument(
417
424
  'filename',
@@ -256,6 +256,16 @@ def enqueue_lines(stream: IO[str], output_queue: queue.Queue[str]) -> None:
256
256
  output_queue.put(line.rstrip())
257
257
 
258
258
 
259
+ def _terminate(process: subprocess.Popen) -> None:
260
+ """Terminate a subprocess, killing it if it does not exit in time."""
261
+ process.terminate()
262
+ try:
263
+ process.wait(timeout=5)
264
+ except subprocess.TimeoutExpired:
265
+ log.error("Subprocess did not terminate in time. Forcing kill...")
266
+ process.kill()
267
+
268
+
259
269
  def run_with_output(command: list[str], cwd: str = '.',
260
270
  on_output: Callable[..., None] | None = None,
261
271
  env: dict[str, str] | None = None) -> RunResult:
@@ -268,6 +278,8 @@ def run_with_output(command: list[str], cwd: str = '.',
268
278
  on_output: Callback function for processing output lines
269
279
  If set the funciton will be called with each
270
280
  line and stream (stdout/stderr) as they are written.
281
+ An exception raised from it terminates the command
282
+ and propagates.
271
283
  env: Optional environment variables to add or override
272
284
 
273
285
  Returns:
@@ -352,14 +364,14 @@ def run_with_output(command: list[str], cwd: str = '.',
352
364
  except KeyboardInterrupt:
353
365
  log.stop_spinner()
354
366
  log.error("CTRL-C pressed, terminate subprocess")
355
- process.terminate()
356
- try:
357
- process.wait(timeout=5)
358
- except subprocess.TimeoutExpired:
359
- log.error(
360
- "Subprocess did not terminate in time. Forcing kill...")
361
- process.kill()
367
+ _terminate(process)
362
368
  sys.exit(1)
369
+ except Exception:
370
+ # on_output raising aborts the command: stop the subprocess
371
+ # rather than letting it run on unobserved.
372
+ log.stop_spinner()
373
+ _terminate(process)
374
+ raise
363
375
 
364
376
  log.stop_spinner()
365
377
 
@@ -13,6 +13,27 @@ from . import CONFIG_DIR
13
13
  _BARE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
14
14
 
15
15
 
16
+ def ensure_config_dir(workspace_dir: str) -> None:
17
+ """Create the config dir with a .gitignore that ignores everything in it.
18
+
19
+ Nothing in the config dir belongs in git. A .gitignore of its own keeps
20
+ it out without touching the workspace .gitignore, which may be checked
21
+ into Perforce. The file is rewritten when its content differs, which
22
+ also replaces the state.toml-only ignore file of earlier versions."""
23
+ config_dir = os.path.join(workspace_dir, CONFIG_DIR)
24
+ os.makedirs(config_dir, exist_ok=True)
25
+ gitignore = os.path.join(config_dir, '.gitignore')
26
+ content = '*\n'
27
+ try:
28
+ with open(gitignore, encoding='utf-8') as f:
29
+ if f.read() == content:
30
+ return
31
+ except FileNotFoundError:
32
+ pass
33
+ with open(gitignore, 'w', encoding='utf-8') as f:
34
+ f.write(content)
35
+
36
+
16
37
  def config_path(workspace_dir: str) -> str:
17
38
  """Return the path to the config file."""
18
39
  return os.path.join(workspace_dir, CONFIG_DIR, 'config.toml')
@@ -49,6 +70,7 @@ def save_config(workspace_dir: str, config: dict) -> None:
49
70
  merged = load_config(workspace_dir)
50
71
  for section, values in config.items():
51
72
  merged.setdefault(section, {}).update(values)
73
+ ensure_config_dir(workspace_dir)
52
74
  write_toml(config_path(workspace_dir), merged)
53
75
 
54
76
 
@@ -452,6 +452,16 @@ def parse_p4_sync_line(line: str) -> tuple[str | None, str | None]:
452
452
  return (None, None)
453
453
 
454
454
 
455
+ # p4 writes a synced file to a temp file and renames it into place. On
456
+ # Windows the rename fails when another process holds the target open, e.g.
457
+ # an editor with the asset loaded, leaving the file unsynced.
458
+ _RENAME_FAILED_PREFIX = 'rename: failed to rename '
459
+
460
+
461
+ class P4SyncAbortError(CommandError):
462
+ """Raised to stop a p4 sync that can no longer succeed."""
463
+
464
+
455
465
  class P4SyncOutputProcessor:
456
466
  """Process p4 sync output in real-time."""
457
467
 
@@ -465,6 +475,14 @@ class P4SyncOutputProcessor:
465
475
  log.info('all files up to date')
466
476
  return
467
477
 
478
+ if line.startswith(_RENAME_FAILED_PREFIX):
479
+ log.error(line)
480
+ raise P4SyncAbortError(
481
+ 'Aborting sync: p4 could not replace a file, most likely '
482
+ 'because another process has it open. The workspace is left '
483
+ 'partially synced: close that process, then commit or '
484
+ 'restore the synced files before syncing again.')
485
+
468
486
  mode, filename = parse_p4_sync_line(line)
469
487
  if not mode or not filename:
470
488
  log.warning(f'Unparsable line: {line}')
@@ -10,6 +10,7 @@ import os
10
10
  import shlex
11
11
  import subprocess
12
12
  from . import CONFIG_DIR
13
+ from .config import ensure_config_dir
13
14
  from .changelist_store import alias_exists, validate_alias_name
14
15
  from .git import get_commit_lines_since, resolve_editor
15
16
  from .log import log
@@ -112,6 +113,7 @@ def review_command(args: argparse.Namespace) -> int:
112
113
 
113
114
  # Write todo to .git-p4son/reviews/todo
114
115
  reviews_dir = _reviews_dir(workspace_dir)
116
+ ensure_config_dir(workspace_dir)
115
117
  os.makedirs(reviews_dir, exist_ok=True)
116
118
  todo_file = _todo_path(workspace_dir)
117
119
  with open(todo_file, 'w') as f:
@@ -0,0 +1,38 @@
1
+ """
2
+ Local, per-user state for git-p4son.
3
+
4
+ Stored in .git-p4son/state.toml. State here reflects a single user's workspace
5
+ and preferences, e.g. dismissed warnings.
6
+ """
7
+
8
+ import os
9
+
10
+ from . import CONFIG_DIR
11
+ from .config import ensure_config_dir, load_toml, write_toml
12
+
13
+
14
+ def state_path(workspace_dir: str) -> str:
15
+ """Return the path to the local state file."""
16
+ return os.path.join(workspace_dir, CONFIG_DIR, 'state.toml')
17
+
18
+
19
+ def _load(workspace_dir: str) -> dict:
20
+ return load_toml(state_path(workspace_dir))
21
+
22
+
23
+ def _save(workspace_dir: str, state: dict) -> None:
24
+ ensure_config_dir(workspace_dir)
25
+ write_toml(state_path(workspace_dir), state)
26
+
27
+
28
+ def is_clobber_warning_dismissed(workspace_dir: str) -> bool:
29
+ """Return whether the user permanently dismissed the clobber warning."""
30
+ return bool(_load(workspace_dir).get('clobber', {})
31
+ .get('dismiss_warning', False))
32
+
33
+
34
+ def dismiss_clobber_warning(workspace_dir: str) -> None:
35
+ """Persist that the clobber warning should never be shown again."""
36
+ state = _load(workspace_dir)
37
+ state.setdefault('clobber', {})['dismiss_warning'] = True
38
+ _save(workspace_dir, state)
@@ -821,6 +821,17 @@ def sync_command(args: argparse.Namespace) -> int:
821
821
  return 0
822
822
  targets = resolved_targets
823
823
 
824
+ # A dry run stops once the sequence is known: it syncs nothing, so it
825
+ # skips the clobber prompt, the workspace checks and the hooks.
826
+ if vars(args).get('dry_run', False):
827
+ log.heading('Sync sequence')
828
+ if resync_last_synced:
829
+ log.success(f'{last_sync.changelist} ({LAST_SYNCED_LABEL})')
830
+ else:
831
+ log.success(' '.join(str(cl) for cl, _ in targets))
832
+ log.info('Dry run, nothing synced.')
833
+ return 0
834
+
824
835
  # Workspace line ending governs how staged git content is normalized so
825
836
  # the post-sync merge doesn't conflict on LF-vs-CRLF differences alone.
826
837
  # The clobber option changes whether git-ignored writable files survive
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.4.0
3
+ Version: 0.4.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
@@ -277,6 +277,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
277
277
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
278
278
  `.git-p4son/config.toml` and used by all subsequent commands.
279
279
 
280
+ Nothing in `.git-p4son/` belongs in git. The folder has its own `.gitignore` that ignores everything in it, so there
281
+ is no need to list it in the workspace `.gitignore`.
282
+
280
283
  The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
281
284
  `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
282
285
  the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
@@ -295,7 +298,7 @@ saves the answer; run `git p4son writable apply` once your files are committed t
295
298
  Sync local git repository with a Perforce workspace:
296
299
 
297
300
  ```sh
298
- git p4son sync [changelist ...] [--force]
301
+ git p4son sync [changelist ...] [--force] [--dry-run]
299
302
  ```
300
303
 
301
304
  **Arguments:**
@@ -306,6 +309,8 @@ git p4son sync [changelist ...] [--force]
306
309
 
307
310
  **Options:**
308
311
  - `-f, --force`: Allow syncing to changelists older than the current one.
312
+ - `-n, --dry-run`: Print the resolved sync sequence without syncing. The arguments are validated as for a
313
+ real sync, but nothing else runs: no clean-workspace checks, no hooks, and no prompts.
309
314
 
310
315
  **Examples:**
311
316
  ```sh
@@ -316,6 +321,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
316
321
  git p4son sync 123 156 head # sync 123, 156, then the latest changelist
317
322
  git p4son sync last-synced
318
323
  git p4son sync 12345 --force
324
+ git p4son sync --dry-run # show the changelists a sync to latest would visit
319
325
  ```
320
326
 
321
327
  #### pre-sync hook
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-p4son"
7
- version = "0.4.0"
7
+ version = "0.4.1"
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"
@@ -4,6 +4,7 @@ import os
4
4
  import tempfile
5
5
  import unittest
6
6
 
7
+ from git_p4son import CONFIG_DIR
7
8
  from git_p4son.changelist_store import (
8
9
  delete_changelist_alias,
9
10
  load_changelist_alias,
@@ -29,6 +30,12 @@ class TestAliasStoreValidatesNames(unittest.TestCase):
29
30
  self.assertTrue(delete_changelist_alias('feature', self.ws))
30
31
  self.assertIsNone(load_changelist_alias('feature', self.ws))
31
32
 
33
+ def test_save_ensures_config_gitignore(self):
34
+ save_changelist_alias('feature', '123', self.ws)
35
+ gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
36
+ with open(gitignore, encoding='utf-8') as f:
37
+ self.assertEqual(f.read(), '*\n')
38
+
32
39
  def test_load_rejects_path_traversal(self):
33
40
  # The store directory must exist for the relative path to resolve.
34
41
  save_changelist_alias('some-alias', '1', self.ws)
@@ -47,6 +47,12 @@ class TestCreateParser(unittest.TestCase):
47
47
  args = self.parser.parse_args(['sync', '100', '-f'])
48
48
  self.assertTrue(args.force)
49
49
 
50
+ def test_sync_command_dry_run(self):
51
+ self.assertFalse(self.parser.parse_args(['sync']).dry_run)
52
+ for flag in ('--dry-run', '-n'):
53
+ args = self.parser.parse_args(['sync', flag])
54
+ self.assertTrue(args.dry_run)
55
+
50
56
  def test_new_command_no_message(self):
51
57
  args = self.parser.parse_args(['new'])
52
58
  self.assertEqual(args.command, 'new')
@@ -122,6 +128,11 @@ class TestCreateParser(unittest.TestCase):
122
128
  with contextlib.redirect_stderr(io.StringIO()):
123
129
  self.parser.parse_args(['update', '100', '-s', 'abc'])
124
130
 
131
+ def test_sequence_editor_hidden_from_help(self):
132
+ help_text = self.parser.format_help()
133
+ self.assertNotIn('_sequence-editor', help_text)
134
+ self.assertNotIn('SUPPRESS', help_text)
135
+
125
136
  def test_version_flag(self):
126
137
  with self.assertRaises(SystemExit) as ctx:
127
138
  self.parser.parse_args(['--version'])
@@ -4,6 +4,7 @@ import os
4
4
  import subprocess
5
5
  import sys
6
6
  import tempfile
7
+ import time
7
8
  import unittest
8
9
  from unittest import mock
9
10
 
@@ -408,6 +409,24 @@ class TestRunWithOutput(unittest.TestCase):
408
409
  self.assertEqual(result.stderr[-1], 'err499')
409
410
  self.assertEqual(len(callback_lines), 5500)
410
411
 
412
+ def test_callback_exception_terminates_process(self):
413
+ """Raising from on_output stops the command instead of waiting."""
414
+ code = ('import sys, time\n'
415
+ 'print("bad", flush=True)\n'
416
+ 'time.sleep(60)\n')
417
+
418
+ def callback(line, stream):
419
+ raise CommandError(f'abort on {line}')
420
+
421
+ start = time.monotonic()
422
+ with mock.patch('git_p4son.common.log') as mock_log:
423
+ with self.assertRaises(CommandError) as ctx:
424
+ run_with_output([sys.executable, '-c', code],
425
+ on_output=callback)
426
+ self.assertEqual(str(ctx.exception), 'abort on bad')
427
+ self.assertLess(time.monotonic() - start, 30)
428
+ mock_log.stop_spinner.assert_called_once()
429
+
411
430
 
412
431
  class TestMissingExecutable(unittest.TestCase):
413
432
  def test_run_stops_spinner_and_raises(self):
@@ -201,6 +201,8 @@ class TestComplete(unittest.TestCase):
201
201
  names = self._names(result)
202
202
  self.assertIn('-f', names)
203
203
  self.assertIn('--force', names)
204
+ self.assertIn('-n', names)
205
+ self.assertIn('--dry-run', names)
204
206
 
205
207
  # -- new command --
206
208
 
@@ -1,17 +1,59 @@
1
1
  """Tests for git_p4son.config module."""
2
2
 
3
3
  import os
4
+ import subprocess
4
5
  import tempfile
5
6
  import unittest
6
7
 
7
8
  from git_p4son import CONFIG_DIR
8
9
  from git_p4son.config import (
9
10
  config_path,
11
+ ensure_config_dir,
10
12
  load_config,
11
13
  save_config,
12
14
  )
13
15
 
14
16
 
17
+ def _read_config_gitignore(workspace_dir):
18
+ with open(os.path.join(workspace_dir, CONFIG_DIR, '.gitignore'),
19
+ encoding='utf-8') as f:
20
+ return f.read()
21
+
22
+
23
+ class TestEnsureConfigDir(unittest.TestCase):
24
+ def setUp(self):
25
+ self._tempdir = tempfile.TemporaryDirectory()
26
+ self.ws = self._tempdir.name
27
+
28
+ def tearDown(self):
29
+ self._tempdir.cleanup()
30
+
31
+ def test_creates_dir_ignoring_everything(self):
32
+ ensure_config_dir(self.ws)
33
+ self.assertEqual(_read_config_gitignore(self.ws), '*\n')
34
+
35
+ def test_replaces_state_only_gitignore(self):
36
+ """Earlier versions ignored only state.toml."""
37
+ os.makedirs(os.path.join(self.ws, CONFIG_DIR))
38
+ with open(os.path.join(self.ws, CONFIG_DIR, '.gitignore'), 'w',
39
+ encoding='utf-8') as f:
40
+ f.write('state.toml\n')
41
+ ensure_config_dir(self.ws)
42
+ self.assertEqual(_read_config_gitignore(self.ws), '*\n')
43
+
44
+ def test_save_config_ensures_gitignore(self):
45
+ save_config(self.ws, {'depot': {'root': '//ws/...'}})
46
+ self.assertEqual(_read_config_gitignore(self.ws), '*\n')
47
+
48
+ def test_git_ignores_config_dir(self):
49
+ subprocess.run(['git', 'init', '-q', self.ws], check=True)
50
+ save_config(self.ws, {'depot': {'root': '//ws/...'}})
51
+ status = subprocess.run(
52
+ ['git', 'status', '--porcelain', '--untracked-files=all'],
53
+ cwd=self.ws, check=True, capture_output=True, text=True)
54
+ self.assertEqual(status.stdout, '')
55
+
56
+
15
57
  class TestConfigPath(unittest.TestCase):
16
58
  def test_returns_expected_path(self):
17
59
  result = config_path('/workspace')
@@ -0,0 +1,39 @@
1
+ """Tests for git_p4son.state module."""
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ from git_p4son import CONFIG_DIR
8
+ from git_p4son.state import (
9
+ dismiss_clobber_warning,
10
+ is_clobber_warning_dismissed,
11
+ state_path,
12
+ )
13
+
14
+
15
+ class TestClobberWarningState(unittest.TestCase):
16
+ def setUp(self):
17
+ self._tempdir = tempfile.TemporaryDirectory()
18
+ self.ws = self._tempdir.name
19
+
20
+ def tearDown(self):
21
+ self._tempdir.cleanup()
22
+
23
+ def test_default_is_not_dismissed(self):
24
+ self.assertFalse(is_clobber_warning_dismissed(self.ws))
25
+
26
+ def test_dismiss_persists(self):
27
+ dismiss_clobber_warning(self.ws)
28
+ self.assertTrue(is_clobber_warning_dismissed(self.ws))
29
+ self.assertTrue(os.path.exists(state_path(self.ws)))
30
+
31
+ def test_dismiss_ensures_config_gitignore(self):
32
+ dismiss_clobber_warning(self.ws)
33
+ gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
34
+ with open(gitignore, encoding='utf-8') as f:
35
+ self.assertEqual(f.read(), '*\n')
36
+
37
+
38
+ if __name__ == '__main__':
39
+ unittest.main()
@@ -9,6 +9,7 @@ from unittest import mock
9
9
 
10
10
  from git_p4son.common import CommandError, RunError
11
11
  from git_p4son.perforce import (
12
+ P4SyncAbortError,
12
13
  P4SyncOutputProcessor,
13
14
  P4SyncPreviewFile,
14
15
  get_latest_changelist,
@@ -455,6 +456,13 @@ class TestP4SyncOutputProcessor(unittest.TestCase):
455
456
  processor('//...@12345 - file(s) up-to-date.', sys.stdout)
456
457
  self.assertEqual(processor.synced_file_count, 0)
457
458
 
459
+ def test_failed_rename_aborts(self):
460
+ processor = P4SyncOutputProcessor()
461
+ with self.assertRaises(P4SyncAbortError):
462
+ processor('rename: failed to rename D:\\ws\\a.uasset after 10 '
463
+ 'attempts: Cannot create a file when that file '
464
+ 'already exists.', sys.stderr)
465
+
458
466
 
459
467
  class TestP4Sync(unittest.TestCase):
460
468
  @mock.patch('git_p4son.sync.run_with_output')
@@ -1655,6 +1663,82 @@ class TestSyncCommand(unittest.TestCase):
1655
1663
  self.assertEqual(rc, 1)
1656
1664
 
1657
1665
 
1666
+ class TestSyncDryRun(unittest.TestCase):
1667
+ """A dry run resolves the sync sequence, prints it and stops before
1668
+ anything that touches the workspaces or asks the user."""
1669
+
1670
+ def setUp(self):
1671
+ for target, value in (
1672
+ ('git_p4son.depot.get_client_spec', None),
1673
+ ('git_p4son.depot.get_depot_root', '//myclient'),
1674
+ ('git_p4son.sync.get_latest_changelist', 10500)):
1675
+ patcher = mock.patch(target, return_value=value)
1676
+ patcher.start()
1677
+ self.addCleanup(patcher.stop)
1678
+ patcher = mock.patch('git_p4son.sync.git_last_sync',
1679
+ return_value=LastSync(changelist=10000,
1680
+ commit='abc123'))
1681
+ patcher.start()
1682
+ self.addCleanup(patcher.stop)
1683
+ patcher = mock.patch('git_p4son.sync.log')
1684
+ self.mock_log = patcher.start()
1685
+ self.addCleanup(patcher.stop)
1686
+ # Everything past the dry-run exit: none of it may run.
1687
+ self.untouched = {}
1688
+ for name in ('_handle_clobber_warning', 'sync_preflight',
1689
+ 'run_hooks', 'get_head_commit', '_sync_pass',
1690
+ 'commit'):
1691
+ patcher = mock.patch(f'git_p4son.sync.{name}')
1692
+ self.untouched[name] = patcher.start()
1693
+ self.addCleanup(patcher.stop)
1694
+
1695
+ def _run(self, changelist, force=False):
1696
+ args = mock.Mock(changelist=changelist, force=force,
1697
+ workspace_dir='/ws', dry_run=True)
1698
+ return sync_command(args)
1699
+
1700
+ def _sequence(self):
1701
+ """The line logged under the "Sync sequence" heading."""
1702
+ self.mock_log.heading.assert_any_call('Sync sequence')
1703
+ return self.mock_log.success.call_args_list[-1].args[0]
1704
+
1705
+ def _assert_nothing_run(self):
1706
+ for name, patched in self.untouched.items():
1707
+ with self.subTest(name=name):
1708
+ patched.assert_not_called()
1709
+
1710
+ def test_latest(self):
1711
+ self.assertEqual(self._run([]), 0)
1712
+ self.assertEqual(self._sequence(), '10500')
1713
+ self._assert_nothing_run()
1714
+
1715
+ def test_sequence_ending_in_head(self):
1716
+ self.assertEqual(self._run(['10100', '10200', 'head']), 0)
1717
+ self.assertEqual(self._sequence(), '10100 10200 10500')
1718
+ self._assert_nothing_run()
1719
+
1720
+ def test_last_synced(self):
1721
+ self.assertEqual(self._run(['last-synced']), 0)
1722
+ self.assertEqual(self._sequence(), '10000 (last synced)')
1723
+ self._assert_nothing_run()
1724
+
1725
+ def test_older_changelist_with_force(self):
1726
+ self.assertEqual(self._run(['9000'], force=True), 0)
1727
+ self.assertEqual(self._sequence(), '9000')
1728
+ self._assert_nothing_run()
1729
+
1730
+ def test_invalid_arguments_still_fail(self):
1731
+ """A dry run validates the targets exactly as a real sync does."""
1732
+ self.assertEqual(self._run(['9000']), 1)
1733
+ self.mock_log.error.assert_called_once()
1734
+ self._assert_nothing_run()
1735
+
1736
+ def test_already_synced(self):
1737
+ self.assertEqual(self._run(['10000']), 0)
1738
+ self.mock_log.info.assert_any_call('Already synced, nothing to do.')
1739
+ self._assert_nothing_run()
1740
+
1741
+
1658
1742
  class TestMergeChangedFiles(unittest.TestCase):
1659
1743
  """Tests for _merge_changed_files."""
1660
1744
 
@@ -1,56 +0,0 @@
1
- """
2
- Local, per-user state for git-p4son.
3
-
4
- Stored in .git-p4son/state.toml, which is kept out of version control (unlike
5
- config.toml, which carries shared repo setup). State here reflects a single
6
- user's workspace and preferences, e.g. dismissed warnings.
7
- """
8
-
9
- import os
10
-
11
- from . import CONFIG_DIR
12
- from .config import load_toml, write_toml
13
-
14
-
15
- def state_path(workspace_dir: str) -> str:
16
- """Return the path to the local state file."""
17
- return os.path.join(workspace_dir, CONFIG_DIR, 'state.toml')
18
-
19
-
20
- def _load(workspace_dir: str) -> dict:
21
- return load_toml(state_path(workspace_dir))
22
-
23
-
24
- def _save(workspace_dir: str, state: dict) -> None:
25
- write_toml(state_path(workspace_dir), state)
26
- _ensure_gitignored(workspace_dir)
27
-
28
-
29
- def _ensure_gitignored(workspace_dir: str) -> None:
30
- """Make sure state.toml is ignored via a .gitignore inside CONFIG_DIR.
31
-
32
- A self-contained ignore file keeps the local state out of version control
33
- without touching the user's top-level .gitignore."""
34
- gitignore = os.path.join(workspace_dir, CONFIG_DIR, '.gitignore')
35
- entry = 'state.toml'
36
- existing = []
37
- if os.path.exists(gitignore):
38
- with open(gitignore, encoding='utf-8') as f:
39
- existing = [line.strip() for line in f]
40
- if entry in existing:
41
- return
42
- with open(gitignore, 'a', encoding='utf-8') as f:
43
- f.write(f'{entry}\n')
44
-
45
-
46
- def is_clobber_warning_dismissed(workspace_dir: str) -> bool:
47
- """Return whether the user permanently dismissed the clobber warning."""
48
- return bool(_load(workspace_dir).get('clobber', {})
49
- .get('dismiss_warning', False))
50
-
51
-
52
- def dismiss_clobber_warning(workspace_dir: str) -> None:
53
- """Persist that the clobber warning should never be shown again."""
54
- state = _load(workspace_dir)
55
- state.setdefault('clobber', {})['dismiss_warning'] = True
56
- _save(workspace_dir, state)
@@ -1,64 +0,0 @@
1
- """Tests for git_p4son.state module."""
2
-
3
- import os
4
- import tempfile
5
- import unittest
6
-
7
- from git_p4son import CONFIG_DIR
8
- from git_p4son.state import (
9
- dismiss_clobber_warning,
10
- is_clobber_warning_dismissed,
11
- state_path,
12
- )
13
-
14
-
15
- class TestClobberWarningState(unittest.TestCase):
16
- def setUp(self):
17
- self._tempdir = tempfile.TemporaryDirectory()
18
- self.ws = self._tempdir.name
19
-
20
- def tearDown(self):
21
- self._tempdir.cleanup()
22
-
23
- def test_default_is_not_dismissed(self):
24
- self.assertFalse(is_clobber_warning_dismissed(self.ws))
25
-
26
- def test_dismiss_persists(self):
27
- dismiss_clobber_warning(self.ws)
28
- self.assertTrue(is_clobber_warning_dismissed(self.ws))
29
- self.assertTrue(os.path.exists(state_path(self.ws)))
30
-
31
- def test_dismiss_is_gitignored(self):
32
- """The state file must be kept out of version control via a
33
- .gitignore inside the config dir."""
34
- dismiss_clobber_warning(self.ws)
35
- gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
36
- self.assertTrue(os.path.exists(gitignore))
37
- with open(gitignore, encoding='utf-8') as f:
38
- entries = [line.strip() for line in f]
39
- self.assertIn('state.toml', entries)
40
-
41
- def test_gitignore_not_duplicated(self):
42
- dismiss_clobber_warning(self.ws)
43
- dismiss_clobber_warning(self.ws)
44
- gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
45
- with open(gitignore, encoding='utf-8') as f:
46
- entries = [line.strip() for line in f if line.strip()]
47
- self.assertEqual(entries.count('state.toml'), 1)
48
-
49
- def test_dismiss_preserves_existing_gitignore_entries(self):
50
- os.makedirs(os.path.join(self.ws, CONFIG_DIR))
51
- gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
52
- with open(gitignore, 'w', encoding='utf-8') as f:
53
- f.write('other.local\n')
54
-
55
- dismiss_clobber_warning(self.ws)
56
-
57
- with open(gitignore, encoding='utf-8') as f:
58
- entries = [line.strip() for line in f if line.strip()]
59
- self.assertIn('other.local', entries)
60
- self.assertIn('state.toml', entries)
61
-
62
-
63
- if __name__ == '__main__':
64
- 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