git-p4son 0.3.2__tar.gz → 0.3.3__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.2 → git_p4son-0.3.3}/PKG-INFO +19 -3
  2. {git_p4son-0.3.2 → git_p4son-0.3.3}/README.md +18 -2
  3. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/sync.py +181 -121
  5. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/sync_split.py +15 -1
  6. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son.egg-info/PKG-INFO +19 -3
  7. {git_p4son-0.3.2 → git_p4son-0.3.3}/pyproject.toml +1 -1
  8. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_sync.py +11 -4
  9. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_sync_split.py +181 -2
  10. {git_p4son-0.3.2 → git_p4son-0.3.3}/LICENSE +0 -0
  11. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/__main__.py +0 -0
  12. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/alias.py +0 -0
  13. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/changelist_store.py +0 -0
  14. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/cli.py +0 -0
  15. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/common.py +0 -0
  16. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/complete.py +0 -0
  17. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/completions/_git-p4son +0 -0
  18. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/completions/git-p4son.bash +0 -0
  19. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/completions/git-p4son.ps1 +0 -0
  20. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/config.py +0 -0
  21. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/git.py +0 -0
  22. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/hooks.py +0 -0
  23. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/init.py +0 -0
  24. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/lib.py +0 -0
  25. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/list_changes.py +0 -0
  26. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/log.py +0 -0
  27. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/new.py +0 -0
  28. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/perforce.py +0 -0
  29. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/review.py +0 -0
  30. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/state.py +0 -0
  31. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son/update.py +0 -0
  32. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son.egg-info/SOURCES.txt +0 -0
  33. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son.egg-info/dependency_links.txt +0 -0
  34. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son.egg-info/entry_points.txt +0 -0
  35. {git_p4son-0.3.2 → git_p4son-0.3.3}/git_p4son.egg-info/top_level.txt +0 -0
  36. {git_p4son-0.3.2 → git_p4son-0.3.3}/setup.cfg +0 -0
  37. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_alias.py +0 -0
  38. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_changelist_store.py +0 -0
  39. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_cli.py +0 -0
  40. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_common.py +0 -0
  41. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_complete.py +0 -0
  42. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_config.py +0 -0
  43. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_git.py +0 -0
  44. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_hooks.py +0 -0
  45. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_init.py +0 -0
  46. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_lib_changelist.py +0 -0
  47. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_lib_edit.py +0 -0
  48. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_lib_review.py +0 -0
  49. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_list_changes.py +0 -0
  50. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_log.py +0 -0
  51. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_new.py +0 -0
  52. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_perforce.py +0 -0
  53. {git_p4son-0.3.2 → git_p4son-0.3.3}/tests/test_review.py +0 -0
  54. {git_p4son-0.3.2 → git_p4son-0.3.3}/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.2
3
+ Version: 0.3.3
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
@@ -335,7 +335,10 @@ own submits one at a time. Repeat `--user` to split out several people's changel
335
335
  It is equivalent to working out the numbers by hand and running
336
336
  `git p4son sync <before-theirs> <theirs> ... head`, and it delegates to `sync` once the sequence is
337
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`.
338
+ all behave exactly as they do for `sync`. The clean-workspace checks and the pre-sync hooks are the one
339
+ thing that runs earlier: resolving the sequence costs several Perforce queries, so they go first, and a
340
+ dirty workspace or a hook that aborts the sync says so before that work. They run once for the whole sync,
341
+ and not at all for a `--dry-run` or when there is nothing to sync.
339
342
 
340
343
  **Arguments:**
341
344
  - `changelist` (optional): Changelist number to sync up to, or `head` for the latest. Omit to sync to the
@@ -639,12 +642,25 @@ clone, so no `chmod` is needed.
639
642
  #### block-while-running (pre-sync)
640
643
 
641
644
  Aborts the sync while a named process is running, so a sync cannot swap out assets that a running editor still
642
- has loaded:
645
+ has loaded. Link it into your workspace's `pre-sync` hook directory - on macOS and Linux:
643
646
 
644
647
  ```sh
645
648
  ln -s /path/to/git-p4son/examples/hooks/pre-sync/block-while-running.py .git-p4son/hooks/pre-sync/
646
649
  ```
647
650
 
651
+ On Windows, from PowerShell. Note that `-Path` is the link to create and `-Target` the existing file, the
652
+ reverse of the `ln -s` argument order, and that `-Target` should be absolute: a relative path is resolved
653
+ against the current directory and quietly produces a broken link.
654
+
655
+ ```powershell
656
+ New-Item -ItemType SymbolicLink `
657
+ -Path "C:\p4\my-workspace\.git-p4son\hooks\pre-sync\block-while-running.py" `
658
+ -Target "C:\src\git-p4son\examples\hooks\pre-sync\block-while-running.py"
659
+ ```
660
+
661
+ Creating a symlink on Windows needs either an elevated shell or Developer Mode enabled (Settings > System >
662
+ For developers). Without one of those, copy the file instead and re-copy it whenever the clone updates.
663
+
648
664
  Which processes block a sync is read from your workspace's own `.git-p4son/config.toml`, so a symlinked hook
649
665
  stays per-project:
650
666
 
@@ -312,7 +312,10 @@ own submits one at a time. Repeat `--user` to split out several people's changel
312
312
  It is equivalent to working out the numbers by hand and running
313
313
  `git p4son sync <before-theirs> <theirs> ... head`, and it delegates to `sync` once the sequence is
314
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`.
315
+ all behave exactly as they do for `sync`. The clean-workspace checks and the pre-sync hooks are the one
316
+ thing that runs earlier: resolving the sequence costs several Perforce queries, so they go first, and a
317
+ dirty workspace or a hook that aborts the sync says so before that work. They run once for the whole sync,
318
+ and not at all for a `--dry-run` or when there is nothing to sync.
316
319
 
317
320
  **Arguments:**
318
321
  - `changelist` (optional): Changelist number to sync up to, or `head` for the latest. Omit to sync to the
@@ -616,12 +619,25 @@ clone, so no `chmod` is needed.
616
619
  #### block-while-running (pre-sync)
617
620
 
618
621
  Aborts the sync while a named process is running, so a sync cannot swap out assets that a running editor still
619
- has loaded:
622
+ has loaded. Link it into your workspace's `pre-sync` hook directory - on macOS and Linux:
620
623
 
621
624
  ```sh
622
625
  ln -s /path/to/git-p4son/examples/hooks/pre-sync/block-while-running.py .git-p4son/hooks/pre-sync/
623
626
  ```
624
627
 
628
+ On Windows, from PowerShell. Note that `-Path` is the link to create and `-Target` the existing file, the
629
+ reverse of the `ln -s` argument order, and that `-Target` should be absolute: a relative path is resolved
630
+ against the current directory and quietly produces a broken link.
631
+
632
+ ```powershell
633
+ New-Item -ItemType SymbolicLink `
634
+ -Path "C:\p4\my-workspace\.git-p4son\hooks\pre-sync\block-while-running.py" `
635
+ -Target "C:\src\git-p4son\examples\hooks\pre-sync\block-while-running.py"
636
+ ```
637
+
638
+ Creating a symlink on Windows needs either an elevated shell or Developer Mode enabled (Settings > System >
639
+ For developers). Without one of those, copy the file instead and re-copy it whenever the clone updates.
640
+
625
641
  Which processes block a sync is read from your workspace's own `.git-p4son/config.toml`, so a symlinked hook
626
642
  stays per-project:
627
643
 
@@ -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.2"
10
+ __version__ = "0.3.3"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -36,6 +36,9 @@ from .perforce import (
36
36
  )
37
37
 
38
38
 
39
+ LAST_SYNCED_LABEL = 'last synced'
40
+
41
+
39
42
  @dataclass
40
43
  class LastSync:
41
44
  """Info about the most recent p4son sync commit."""
@@ -524,11 +527,50 @@ def _handle_clobber_warning(clobber: bool, workspace_dir: str) -> bool:
524
527
  return True
525
528
 
526
529
 
530
+ def _check_git_workspace_clean(workspace_dir: str) -> bool:
531
+ """Report whether the git workspace has no uncommitted changes."""
532
+ log.heading('Checking git workspace')
533
+ dirty_files = get_dirty_files(workspace_dir)
534
+ if dirty_files:
535
+ for filename, change in dirty_files:
536
+ log.file_change(filename, change)
537
+ log.error('Workspace is not clean')
538
+ return False
539
+ log.success('clean')
540
+ return True
541
+
542
+
543
+ def _check_p4_workspace_clean(depot_root: str, workspace_dir: str) -> bool:
544
+ """Report whether the p4 workspace has no git-tracked files opened.
545
+
546
+ Files opened in Perforce that git does not track cannot collide with the
547
+ commit a sync makes, so they only warrant a warning.
548
+ """
549
+ log.heading('Checking p4 workspace')
550
+ opened_files = p4_get_opened_files(depot_root, workspace_dir)
551
+ tracked_opened_files = [
552
+ (filename, change)
553
+ for filename, change in opened_files
554
+ if is_file_tracked(filename, workspace_dir)
555
+ ]
556
+ if tracked_opened_files:
557
+ for filename, change in tracked_opened_files:
558
+ log.file_change(filename, change)
559
+ log.error('Workspace has p4-opened files tracked by git')
560
+ return False
561
+ if opened_files:
562
+ log.warning(
563
+ f'Ignoring {len(opened_files)} p4-opened files not tracked by git')
564
+ else:
565
+ log.success('Clean')
566
+ return True
567
+
568
+
527
569
  def _run_pre_sync_hooks(workspace_dir: str, invocation_dir: str) -> bool:
528
570
  """Run pre-sync hooks; return False when any hook fails, aborting the sync.
529
571
 
530
- Runs once before any changelist is synced. All hooks run (they are
531
- independent), then the sync is aborted if any returned a non-zero code.
572
+ All hooks run (they are independent), then the sync is aborted if any
573
+ returned a non-zero code.
532
574
  """
533
575
  results = run_hooks('pre-sync', workspace_dir, invocation_dir)
534
576
  if any(result.returncode != 0 for result in results):
@@ -537,6 +579,26 @@ def _run_pre_sync_hooks(workspace_dir: str, invocation_dir: str) -> bool:
537
579
  return True
538
580
 
539
581
 
582
+ def sync_preflight(depot_root: str, workspace_dir: str, invocation_dir: str,
583
+ already_done: bool = False) -> bool:
584
+ """Gate a sync: both workspaces clean, then the pre-sync hooks.
585
+
586
+ Returns False when the sync must not go ahead. Callers run this once they
587
+ know a sync will actually be attempted, so that a run with nothing to sync
588
+ neither checks the workspaces nor fires the hooks. already_done covers a
589
+ caller that ran it before delegating here: sync-split runs it up front,
590
+ ahead of the p4 queries that resolve its changelist sequence, so neither
591
+ the checks nor the hooks happen twice.
592
+ """
593
+ if already_done:
594
+ return True
595
+ if not _check_git_workspace_clean(workspace_dir):
596
+ return False
597
+ if not _check_p4_workspace_clean(depot_root, workspace_dir):
598
+ return False
599
+ return _run_pre_sync_hooks(workspace_dir, invocation_dir)
600
+
601
+
540
602
  def _sync_pass(changelist: int, label: str, depot_root: str,
541
603
  workspace_dir: str, pre_sync_head_commit: str, temp_root: str,
542
604
  uses_crlf: bool, clobber: bool) -> WritableSyncFileSet:
@@ -587,10 +649,82 @@ def resolve_depot_root(workspace_dir: str) -> ResolvedDepot | None:
587
649
  return ResolvedDepot(depot_root=depot_root, client_spec=client_spec)
588
650
 
589
651
 
652
+ def _latest_target(depot_root: str, workspace_dir: str) -> tuple[int, str]:
653
+ """Look up the latest submitted changelist as a sync target."""
654
+ log.heading('Finding latest changelist')
655
+ latest = get_latest_changelist(depot_root, workspace_dir)
656
+ log.success(f'CL {latest}')
657
+ return (latest, 'latest')
658
+
659
+
660
+ def _resolve_sync_targets(
661
+ raw: list[str], last_sync: LastSync | None, depot_root: str,
662
+ workspace_dir: str, force: bool) -> list[tuple[int, str]] | None:
663
+ """Resolve the ordered (changelist, label) targets a sync will visit.
664
+
665
+ Returns None when the arguments are invalid, and an empty list when the
666
+ workspace already sits at the only changelist asked for.
667
+ """
668
+ lowered = [c.lower() for c in raw]
669
+
670
+ # "head" resolves to the latest changelist, the largest in an increasing
671
+ # sequence, so it may only appear as the final target.
672
+ if 'head' in lowered and lowered.index('head') != len(lowered) - 1:
673
+ log.error('The "head" keyword must come last')
674
+ return None
675
+
676
+ if not raw:
677
+ targets = [_latest_target(depot_root, workspace_dir)]
678
+ else:
679
+ targets = []
680
+ for c in raw:
681
+ if c.lower() == 'head':
682
+ targets.append(_latest_target(depot_root, workspace_dir))
683
+ else:
684
+ try:
685
+ targets.append((int(c), 'specified'))
686
+ except ValueError:
687
+ log.error(f'Invalid changelist number: {c}')
688
+ return None
689
+
690
+ # Targets are synced in the given order, so they must be strictly
691
+ # increasing: no syncing back and forth.
692
+ numbers = [cl for cl, _ in targets]
693
+ for prev, curr in zip(numbers, numbers[1:]):
694
+ if curr <= prev:
695
+ log.error('Changelists must be strictly increasing, '
696
+ f'got {prev} then {curr}')
697
+ return None
698
+
699
+ # Syncing to a changelist older than the current one needs --force.
700
+ # Targets are strictly increasing, so the smallest is numbers[0]; an
701
+ # equal-to-current changelist (dropped just below) is not "older".
702
+ if last_sync and numbers[0] < last_sync.changelist:
703
+ if not force:
704
+ log.error(
705
+ f'Cannot sync to CL {numbers[0]} '
706
+ f'(currently at CL {last_sync.changelist}) without --force.')
707
+ return None
708
+ log.warning(
709
+ f'Syncing to older CL {numbers[0]} '
710
+ f'(currently at CL {last_sync.changelist}) with --force')
711
+
712
+ # Drop a target equal to the last synced changelist: we are already
713
+ # there, so there is nothing to sync or commit for it. An explicitly
714
+ # requested *older* changelist is kept and synced as the user asked.
715
+ if last_sync and last_sync.changelist in numbers:
716
+ log.info(f'Skipping CL {last_sync.changelist} (already synced)')
717
+ targets = [(cl, lbl) for cl, lbl in targets
718
+ if cl != last_sync.changelist]
719
+
720
+ return targets
721
+
722
+
590
723
  def sync_command(args: argparse.Namespace) -> int:
591
724
  """Execute the sync command."""
592
725
  workspace_dir = args.workspace_dir
593
726
  invocation_dir = vars(args).get('invocation_dir', workspace_dir)
727
+ preflight_done = vars(args).get('preflight_done', False)
594
728
 
595
729
  resolved = resolve_depot_root(workspace_dir)
596
730
  if resolved is None:
@@ -598,44 +732,40 @@ def sync_command(args: argparse.Namespace) -> int:
598
732
  depot_root = resolved.depot_root
599
733
  client_spec = resolved.client_spec
600
734
 
601
- log.heading('Checking git workspace')
602
- dirty_files = get_dirty_files(workspace_dir)
603
- if dirty_files:
604
- for filename, change in dirty_files:
605
- log.file_change(filename, change)
606
- log.error('Workspace is not clean')
607
- return 1
608
- log.success('clean')
609
-
610
- log.heading('Checking p4 workspace')
611
- opened_files = p4_get_opened_files(depot_root, workspace_dir)
612
- tracked_opened_files = [
613
- (filename, change)
614
- for filename, change in opened_files
615
- if is_file_tracked(filename, workspace_dir)
616
- ]
617
- if tracked_opened_files:
618
- for filename, change in tracked_opened_files:
619
- log.file_change(filename, change)
620
- log.error('Workspace has p4-opened files tracked by git')
621
- return 1
622
- if opened_files:
623
- log.warning(
624
- f'Ignoring {len(opened_files)} p4-opened files not tracked by git')
625
- else:
626
- log.success('Clean')
627
-
628
- last_changelist_label = 'last synced'
629
- log.heading(f'Finding {last_changelist_label} changelist')
735
+ log.heading(f'Finding {LAST_SYNCED_LABEL} changelist')
630
736
  last_sync = git_last_sync(workspace_dir)
631
737
  if last_sync:
632
738
  log.success(f'CL {last_sync.changelist}')
633
739
  else:
634
740
  log.warning('No previous sync found')
635
741
 
636
- log.heading('Finding HEAD commit')
637
- pre_sync_head_commit = get_head_commit(workspace_dir)
638
- log.success(f'{pre_sync_head_commit}')
742
+ # Work out what the sync will do before touching either workspace, so a
743
+ # run with nothing to sync gets through without checks or hooks.
744
+ lowered = [c.lower() for c in args.changelist]
745
+
746
+ # "last-synced" is a keyword that only makes sense on its own. It re-syncs
747
+ # the changelist git is already at rather than advancing, so it resolves
748
+ # to no targets and takes its own path further down.
749
+ resync_last_synced = 'last-synced' in lowered
750
+ targets: list[tuple[int, str]] = []
751
+ if resync_last_synced:
752
+ if lowered != ['last-synced']:
753
+ log.error('The "last-synced" keyword cannot be combined with '
754
+ 'other changelists')
755
+ return 1
756
+ if not last_sync:
757
+ log.error('No previous sync found, cannot use "last-synced"')
758
+ return 1
759
+ else:
760
+ resolved_targets = _resolve_sync_targets(
761
+ args.changelist, last_sync, depot_root, workspace_dir, args.force)
762
+ if resolved_targets is None:
763
+ return 1
764
+ if not resolved_targets:
765
+ log.info('Already synced, nothing to do.')
766
+ log.heading('Skipping post-sync hooks')
767
+ return 0
768
+ targets = resolved_targets
639
769
 
640
770
  # Workspace line ending governs how staged git content is normalized so
641
771
  # the post-sync merge doesn't conflict on LF-vs-CRLF differences alone.
@@ -645,112 +775,42 @@ def sync_command(args: argparse.Namespace) -> int:
645
775
  uses_crlf = bool(client_spec and client_spec.uses_crlf)
646
776
  clobber = bool(client_spec and client_spec.clobber)
647
777
 
778
+ # Prompted before the preflight so declining here costs nothing: no
779
+ # workspace queries, and no hooks fired for a sync that is abandoned.
648
780
  if not _handle_clobber_warning(clobber, workspace_dir):
649
781
  return 1
650
782
 
783
+ # The single gate: both workspaces clean, then the pre-sync hooks. Runs
784
+ # once for the whole sync, covering the catch-up pass as well, and is
785
+ # skipped outright when the caller (sync-split) already ran it.
786
+ if not sync_preflight(depot_root, workspace_dir, invocation_dir,
787
+ preflight_done):
788
+ return 1
789
+
790
+ log.heading('Finding HEAD commit')
791
+ pre_sync_head_commit = get_head_commit(workspace_dir)
792
+ log.success(f'{pre_sync_head_commit}')
793
+
651
794
  # Temp root for staging HEAD/baseline file content between classification
652
795
  # and the post-sync merge. Cleaned up automatically on exit.
653
796
  with tempfile.TemporaryDirectory(prefix='git-p4son-sync-') as temp_root:
654
- raw = args.changelist
655
- lowered = [c.lower() for c in raw]
656
-
657
- # "last-synced" is a keyword that only makes sense on its own.
658
- if 'last-synced' in lowered:
659
- if lowered != ['last-synced']:
660
- log.error('The "last-synced" keyword cannot be combined with '
661
- 'other changelists')
662
- return 1
663
- if not last_sync:
664
- log.error('No previous sync found, cannot use "last-synced"')
665
- return 1
666
- if not _run_pre_sync_hooks(workspace_dir, invocation_dir):
667
- return 1
668
- _sync_pass(last_sync.changelist, last_changelist_label, depot_root,
797
+ if resync_last_synced:
798
+ _sync_pass(last_sync.changelist, LAST_SYNCED_LABEL, depot_root,
669
799
  workspace_dir, pre_sync_head_commit, temp_root,
670
800
  uses_crlf, clobber)
671
801
  run_hooks('post-sync', workspace_dir, invocation_dir)
672
802
  return 0
673
803
 
674
- # "head" resolves to the latest changelist, the largest in an
675
- # increasing sequence, so it may only appear as the final target.
676
- if 'head' in lowered and lowered.index('head') != len(lowered) - 1:
677
- log.error('The "head" keyword must come last')
678
- return 1
679
-
680
- # Resolve the ordered list of (changelist, label) targets to sync,
681
- # replacing a trailing "head" with the concrete latest changelist so
682
- # the increasing-order check below compares real numbers.
683
- if not raw:
684
- log.heading('Finding latest changelist')
685
- latest = get_latest_changelist(depot_root, workspace_dir)
686
- log.success(f'CL {latest}')
687
- targets = [(latest, 'latest')]
688
- else:
689
- targets = []
690
- for c in raw:
691
- if c.lower() == 'head':
692
- log.heading('Finding latest changelist')
693
- latest = get_latest_changelist(depot_root, workspace_dir)
694
- log.success(f'CL {latest}')
695
- targets.append((latest, 'latest'))
696
- else:
697
- try:
698
- targets.append((int(c), 'specified'))
699
- except ValueError:
700
- log.error(f'Invalid changelist number: {c}')
701
- return 1
702
-
703
- # Targets are synced in the given order, so they must be strictly
704
- # increasing: no syncing back and forth.
705
- numbers = [cl for cl, _ in targets]
706
- for prev, curr in zip(numbers, numbers[1:]):
707
- if curr <= prev:
708
- log.error('Changelists must be strictly increasing, '
709
- f'got {prev} then {curr}')
710
- return 1
711
-
712
- last_changelist = last_sync.changelist if last_sync else None
713
-
714
- # Syncing to a changelist older than the current one needs --force.
715
- # Targets are strictly increasing, so the smallest is numbers[0]; an
716
- # equal-to-current changelist (dropped just below) is not "older".
717
- if last_sync and numbers[0] < last_sync.changelist:
718
- if not args.force:
719
- log.error(
720
- f'Cannot sync to CL {numbers[0]} '
721
- f'(currently at CL {last_sync.changelist}) without --force.')
722
- return 1
723
- log.warning(
724
- f'Syncing to older CL {numbers[0]} '
725
- f'(currently at CL {last_sync.changelist}) with --force')
726
-
727
- # Drop a target equal to the last synced changelist: we are already
728
- # there, so there is nothing to sync or commit for it. An explicitly
729
- # requested *older* changelist is kept and synced as the user asked.
730
- if last_changelist is not None and last_changelist in numbers:
731
- log.info(f'Skipping CL {last_changelist} (already synced)')
732
- targets = [(cl, lbl) for cl, lbl in targets
733
- if cl != last_changelist]
734
-
735
- if not targets:
736
- log.info('Already synced, nothing to do.')
737
- log.heading('Skipping post-sync hooks')
738
- return 0
739
-
740
- # Pre-sync hooks run once, before syncing any changelist (including the
741
- # catch-up pass), and abort the whole sync if any of them fail.
742
- if not _run_pre_sync_hooks(workspace_dir, invocation_dir):
743
- return 1
744
-
745
804
  all_changed: list[ChangedFile] = []
746
805
  all_ignored: list[str] = []
806
+ last_changelist = last_sync.changelist if last_sync else None
747
807
 
748
808
  # Catch-up pass to the last synced changelist. This makes locally
749
809
  # modified (writable) files read-only and stages their content so the
750
810
  # post-sync merge can restore local changes; its result is folded into
751
811
  # the first target commit.
752
812
  if last_changelist is not None:
753
- prep = _sync_pass(last_changelist, last_changelist_label,
813
+ prep = _sync_pass(last_changelist, LAST_SYNCED_LABEL,
754
814
  depot_root, workspace_dir, pre_sync_head_commit,
755
815
  temp_root, uses_crlf, clobber)
756
816
  all_changed.extend(prep.changed)
@@ -11,7 +11,9 @@ from .perforce import (
11
11
  get_p4_user,
12
12
  get_submitted_changes,
13
13
  )
14
- from .sync import git_last_sync, resolve_depot_root, sync_command
14
+ from .sync import (
15
+ git_last_sync, resolve_depot_root, sync_command, sync_preflight,
16
+ )
15
17
 
16
18
 
17
19
  def build_sync_targets(changes: list[P4Change], users: list[str],
@@ -70,6 +72,7 @@ def _resolve_users(args: argparse.Namespace, workspace_dir: str) -> list[str]:
70
72
  def sync_split_command(args: argparse.Namespace) -> int:
71
73
  """Execute the sync-split command."""
72
74
  workspace_dir = args.workspace_dir
75
+ invocation_dir = vars(args).get('invocation_dir', workspace_dir)
73
76
 
74
77
  resolved = resolve_depot_root(workspace_dir)
75
78
  if resolved is None:
@@ -108,6 +111,17 @@ def sync_split_command(args: argparse.Namespace) -> int:
108
111
  'changelist.')
109
112
  return 1
110
113
 
114
+ # The workspace checks and pre-sync hooks run here rather than being left
115
+ # to sync_command, which only reaches them after all of the resolution
116
+ # below. That resolution costs several p4 round trips, so a dirty
117
+ # workspace or a hook vetoing the sync should get to say so first.
118
+ # sync_command is told it is done so none of it happens twice, and a dry
119
+ # run syncs nothing, so it skips the gate entirely.
120
+ if not args.dry_run:
121
+ if not sync_preflight(depot_root, workspace_dir, invocation_dir):
122
+ return 1
123
+ args.preflight_done = True
124
+
111
125
  users = _resolve_users(args, workspace_dir)
112
126
  if not users:
113
127
  return 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
@@ -335,7 +335,10 @@ own submits one at a time. Repeat `--user` to split out several people's changel
335
335
  It is equivalent to working out the numbers by hand and running
336
336
  `git p4son sync <before-theirs> <theirs> ... head`, and it delegates to `sync` once the sequence is
337
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`.
338
+ all behave exactly as they do for `sync`. The clean-workspace checks and the pre-sync hooks are the one
339
+ thing that runs earlier: resolving the sequence costs several Perforce queries, so they go first, and a
340
+ dirty workspace or a hook that aborts the sync says so before that work. They run once for the whole sync,
341
+ and not at all for a `--dry-run` or when there is nothing to sync.
339
342
 
340
343
  **Arguments:**
341
344
  - `changelist` (optional): Changelist number to sync up to, or `head` for the latest. Omit to sync to the
@@ -639,12 +642,25 @@ clone, so no `chmod` is needed.
639
642
  #### block-while-running (pre-sync)
640
643
 
641
644
  Aborts the sync while a named process is running, so a sync cannot swap out assets that a running editor still
642
- has loaded:
645
+ has loaded. Link it into your workspace's `pre-sync` hook directory - on macOS and Linux:
643
646
 
644
647
  ```sh
645
648
  ln -s /path/to/git-p4son/examples/hooks/pre-sync/block-while-running.py .git-p4son/hooks/pre-sync/
646
649
  ```
647
650
 
651
+ On Windows, from PowerShell. Note that `-Path` is the link to create and `-Target` the existing file, the
652
+ reverse of the `ln -s` argument order, and that `-Target` should be absolute: a relative path is resolved
653
+ against the current directory and quietly produces a broken link.
654
+
655
+ ```powershell
656
+ New-Item -ItemType SymbolicLink `
657
+ -Path "C:\p4\my-workspace\.git-p4son\hooks\pre-sync\block-while-running.py" `
658
+ -Target "C:\src\git-p4son\examples\hooks\pre-sync\block-while-running.py"
659
+ ```
660
+
661
+ Creating a symlink on Windows needs either an elevated shell or Developer Mode enabled (Settings > System >
662
+ For developers). Without one of those, copy the file instead and re-copy it whenever the clone updates.
663
+
648
664
  Which processes block a sync is read from your workspace's own `.git-p4son/config.toml`, so a symlinked hook
649
665
  stays per-project:
650
666
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-p4son"
7
- version = "0.3.2"
7
+ version = "0.3.3"
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"
@@ -866,21 +866,23 @@ class TestSyncCommand(unittest.TestCase):
866
866
  rc = sync_command(args)
867
867
  self.assertEqual(rc, 1)
868
868
 
869
+ @mock.patch('git_p4son.sync.git_last_sync', return_value=None)
869
870
  @mock.patch('git_p4son.sync.get_dirty_files',
870
871
  return_value=[('file.txt', 'modify')])
871
872
  @mock.patch('git_p4son.sync.get_depot_root', return_value='//myclient')
872
- def test_dirty_git_workspace_aborts(self, _depot, _git_clean):
873
+ def test_dirty_git_workspace_aborts(self, _depot, _git_clean, _last_sync):
873
874
  args = mock.Mock(changelist=['100'], force=False, workspace_dir='/ws')
874
875
  rc = sync_command(args)
875
876
  self.assertEqual(rc, 1)
876
877
 
878
+ @mock.patch('git_p4son.sync.git_last_sync', return_value=None)
877
879
  @mock.patch('git_p4son.sync.is_file_tracked', return_value=True)
878
880
  @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
879
881
  @mock.patch('git_p4son.sync.p4_get_opened_files',
880
882
  return_value=[('foo.txt', 'modify')])
881
883
  @mock.patch('git_p4son.sync.get_depot_root', return_value='//myclient')
882
884
  def test_dirty_p4_workspace_aborts(self, _depot, _p4clean, _git_clean,
883
- _tracked):
885
+ _tracked, _last_sync):
884
886
  args = mock.Mock(changelist=['100'], force=False, workspace_dir='/ws')
885
887
  rc = sync_command(args)
886
888
  self.assertEqual(rc, 1)
@@ -943,12 +945,13 @@ class TestSyncCommand(unittest.TestCase):
943
945
 
944
946
  @mock.patch('git_p4son.sync._handle_clobber_warning', return_value=False)
945
947
  @mock.patch('git_p4son.sync.get_head_commit', return_value='def456')
948
+ @mock.patch('git_p4son.sync.get_latest_changelist', return_value=200)
946
949
  @mock.patch('git_p4son.sync.git_last_sync', return_value=None)
947
950
  @mock.patch('git_p4son.sync.p4_get_opened_files', return_value=[])
948
951
  @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
949
952
  @mock.patch('git_p4son.sync.get_depot_root', return_value='//myclient')
950
953
  def test_aborts_when_clobber_warning_declined(
951
- self, _depot, _git_clean, _p4clean, _last_sync, _head,
954
+ self, _depot, _git_clean, _p4clean, _last_sync, _latest, _head,
952
955
  _warn):
953
956
  args = mock.Mock(changelist=[], force=False, workspace_dir='/ws')
954
957
  rc = sync_command(args)
@@ -961,12 +964,16 @@ class TestSyncCommand(unittest.TestCase):
961
964
  @mock.patch('git_p4son.sync.p4_get_opened_files', return_value=[])
962
965
  @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
963
966
  @mock.patch('git_p4son.sync.get_depot_root', return_value='//myclient')
964
- def test_same_cl_is_noop(self, _depot, _git_clean, _p4clean,
967
+ def test_same_cl_is_noop(self, _depot, mock_git_clean, mock_p4clean,
965
968
  mock_last_sync, _head, mock_run_hooks, mock_log):
966
969
  mock_last_sync.return_value = LastSync(changelist=100, commit='abc')
967
970
  args = mock.Mock(changelist=['100'], force=False, workspace_dir='/ws')
968
971
  rc = sync_command(args)
969
972
  self.assertEqual(rc, 0)
973
+ # Nothing to sync, so the whole preflight is skipped: no workspace
974
+ # queries and no hooks, not merely no hooks.
975
+ mock_git_clean.assert_not_called()
976
+ mock_p4clean.assert_not_called()
970
977
  mock_run_hooks.assert_not_called()
971
978
  mock_log.heading.assert_any_call('Skipping post-sync hooks')
972
979
 
@@ -1,13 +1,15 @@
1
1
  """Tests for git_p4son.sync_split module."""
2
2
 
3
+ import argparse
3
4
  import unittest
4
5
  from unittest import mock
5
6
 
6
7
  from git_p4son.perforce import P4Change
7
- from git_p4son.sync import LastSync, ResolvedDepot
8
+ from git_p4son.sync import LastSync, ResolvedDepot, sync_preflight
8
9
  from git_p4son.sync_split import (
9
10
  build_sync_targets, sync_split_command,
10
11
  )
12
+ from tests.helpers import make_run_result
11
13
 
12
14
 
13
15
  def _changes(*pairs):
@@ -97,7 +99,7 @@ class TestBuildSyncTargets(unittest.TestCase):
97
99
  self.assertEqual(targets, [101, 102, 103, 104, 105])
98
100
 
99
101
 
100
- class TestSyncMineCommand(unittest.TestCase):
102
+ class TestSyncSplitCommand(unittest.TestCase):
101
103
 
102
104
  def setUp(self):
103
105
  patcher = mock.patch(
@@ -106,6 +108,11 @@ class TestSyncMineCommand(unittest.TestCase):
106
108
  client_spec=None))
107
109
  patcher.start()
108
110
  self.addCleanup(patcher.stop)
111
+ # The gate has its own tests; here it must simply not shell out.
112
+ gate = mock.patch('git_p4son.sync_split.sync_preflight',
113
+ return_value=True)
114
+ gate.start()
115
+ self.addCleanup(gate.stop)
109
116
 
110
117
  def _args(self, **overrides):
111
118
  values = dict(changelist=None, user=None, dry_run=False,
@@ -221,6 +228,178 @@ class TestSyncMineCommand(unittest.TestCase):
221
228
  rc = sync_split_command(self._args())
222
229
  self.assertEqual(rc, 1)
223
230
 
231
+
232
+ class TestSyncSplitPreSyncHooks(unittest.TestCase):
233
+ """The hooks must fire before the p4 round trips they exist to save,
234
+ exactly once, and never for a run that syncs nothing."""
235
+
236
+ def setUp(self):
237
+ patcher = mock.patch(
238
+ 'git_p4son.sync_split.resolve_depot_root',
239
+ return_value=ResolvedDepot(depot_root='//myclient',
240
+ client_spec=None))
241
+ patcher.start()
242
+ self.addCleanup(patcher.stop)
243
+
244
+ def _args(self, **overrides):
245
+ values = dict(changelist=None, user=None, dry_run=False,
246
+ workspace_dir='/ws', invocation_dir='/invoked')
247
+ values.update(overrides)
248
+ return argparse.Namespace(**values)
249
+
250
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
251
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
252
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value='me')
253
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
254
+ @mock.patch('git_p4son.sync_split.git_last_sync',
255
+ return_value=LastSync(changelist=100, commit='abc123'))
256
+ @mock.patch('git_p4son.sync_split.sync_preflight', return_value=True)
257
+ def test_hooks_run_before_the_expensive_queries(
258
+ self, mock_hooks, _last_sync, _latest, mock_user, mock_changes,
259
+ _sync):
260
+ """The p4 user and changes lookups must not happen until the hooks
261
+ have had their say."""
262
+ order = []
263
+ mock_hooks.side_effect = lambda *a, **k: order.append('hooks') or True
264
+ mock_user.side_effect = lambda *a: order.append('p4 info') or 'me'
265
+ mock_changes.side_effect = lambda *a: order.append('p4 changes') or []
266
+ rc = sync_split_command(self._args())
267
+ self.assertEqual(rc, 0)
268
+ self.assertEqual(order, ['hooks', 'p4 info', 'p4 changes'])
269
+
270
+ @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
271
+ @mock.patch('git_p4son.sync_split.get_submitted_changes', return_value=[])
272
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value='me')
273
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
274
+ @mock.patch('git_p4son.sync_split.git_last_sync',
275
+ return_value=LastSync(changelist=100, commit='abc123'))
276
+ @mock.patch('git_p4son.sync_split.sync_preflight', return_value=True)
277
+ def test_sync_command_is_told_not_to_rerun_them(
278
+ self, _hooks, _last_sync, _latest, _user, _changes, _sync):
279
+ args = self._args()
280
+ sync_split_command(args)
281
+ self.assertTrue(args.preflight_done)
282
+
283
+ @mock.patch('git_p4son.sync_split.get_submitted_changes')
284
+ @mock.patch('git_p4son.sync_split.get_p4_user')
285
+ @mock.patch('git_p4son.sync_split.sync_command')
286
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
287
+ @mock.patch('git_p4son.sync_split.git_last_sync',
288
+ return_value=LastSync(changelist=100, commit='abc123'))
289
+ @mock.patch('git_p4son.sync_split.sync_preflight', return_value=False)
290
+ def test_failing_hook_aborts_before_any_query(
291
+ self, _hooks, _last_sync, _latest, mock_sync, mock_user,
292
+ mock_changes):
293
+ rc = sync_split_command(self._args())
294
+ self.assertEqual(rc, 1)
295
+ mock_user.assert_not_called()
296
+ mock_changes.assert_not_called()
297
+ mock_sync.assert_not_called()
298
+
299
+ @mock.patch('git_p4son.sync_split.get_submitted_changes', return_value=[])
300
+ @mock.patch('git_p4son.sync_split.get_p4_user', return_value='me')
301
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=103)
302
+ @mock.patch('git_p4son.sync_split.git_last_sync',
303
+ return_value=LastSync(changelist=100, commit='abc123'))
304
+ @mock.patch('git_p4son.sync_split.sync_preflight')
305
+ def test_dry_run_does_not_fire_hooks(
306
+ self, mock_hooks, _last_sync, _latest, _user, _changes):
307
+ rc = sync_split_command(self._args(dry_run=True))
308
+ self.assertEqual(rc, 0)
309
+ mock_hooks.assert_not_called()
310
+
311
+ @mock.patch('git_p4son.sync_split.get_latest_changelist', return_value=100)
312
+ @mock.patch('git_p4son.sync_split.git_last_sync',
313
+ return_value=LastSync(changelist=100, commit='abc123'))
314
+ @mock.patch('git_p4son.sync_split.sync_preflight')
315
+ def test_nothing_to_sync_does_not_fire_hooks(
316
+ self, mock_hooks, _last_sync, _latest):
317
+ """Already at the target: sync skips its hooks in that case, and
318
+ so must sync-split."""
319
+ rc = sync_split_command(self._args())
320
+ self.assertEqual(rc, 0)
321
+ mock_hooks.assert_not_called()
322
+
323
+ @mock.patch('git_p4son.sync_split.sync_preflight')
324
+ @mock.patch('git_p4son.sync_split.git_last_sync', return_value=None)
325
+ def test_unsynced_workspace_does_not_fire_hooks(
326
+ self, _last_sync, mock_hooks):
327
+ rc = sync_split_command(self._args())
328
+ self.assertEqual(rc, 1)
329
+ mock_hooks.assert_not_called()
330
+
331
+
332
+ class TestSyncPreflight(unittest.TestCase):
333
+ """The one gate both commands share: workspace checks then hooks, in
334
+ that order, and skipped wholesale when a caller already ran it."""
335
+
336
+ @mock.patch('git_p4son.sync.run_hooks')
337
+ @mock.patch('git_p4son.sync.p4_get_opened_files')
338
+ @mock.patch('git_p4son.sync.get_dirty_files')
339
+ def test_already_done_skips_everything(
340
+ self, mock_dirty, mock_opened, mock_run_hooks):
341
+ self.assertTrue(sync_preflight('//ws', '/ws', '/invoked', True))
342
+ mock_dirty.assert_not_called()
343
+ mock_opened.assert_not_called()
344
+ mock_run_hooks.assert_not_called()
345
+
346
+ @mock.patch('git_p4son.sync.run_hooks', return_value=[])
347
+ @mock.patch('git_p4son.sync.p4_get_opened_files', return_value=[])
348
+ @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
349
+ def test_clean_workspaces_run_the_hooks(
350
+ self, _dirty, _opened, mock_run_hooks):
351
+ self.assertTrue(sync_preflight('//ws', '/ws', '/invoked'))
352
+ mock_run_hooks.assert_called_once_with('pre-sync', '/ws', '/invoked')
353
+
354
+ @mock.patch('git_p4son.sync.run_hooks')
355
+ @mock.patch('git_p4son.sync.p4_get_opened_files')
356
+ @mock.patch('git_p4son.sync.get_dirty_files',
357
+ return_value=[('a.txt', 'modify')])
358
+ def test_dirty_git_stops_before_p4_and_hooks(
359
+ self, _dirty, mock_opened, mock_run_hooks):
360
+ self.assertFalse(sync_preflight('//ws', '/ws', '/invoked'))
361
+ mock_opened.assert_not_called()
362
+ mock_run_hooks.assert_not_called()
363
+
364
+ @mock.patch('git_p4son.sync.run_hooks')
365
+ @mock.patch('git_p4son.sync.is_file_tracked', return_value=True)
366
+ @mock.patch('git_p4son.sync.p4_get_opened_files',
367
+ return_value=[('a.txt', 'modify')])
368
+ @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
369
+ def test_dirty_p4_stops_before_hooks(
370
+ self, _dirty, _opened, _tracked, mock_run_hooks):
371
+ self.assertFalse(sync_preflight('//ws', '/ws', '/invoked'))
372
+ mock_run_hooks.assert_not_called()
373
+
374
+ @mock.patch('git_p4son.sync.run_hooks',
375
+ return_value=[make_run_result(returncode=1)])
376
+ @mock.patch('git_p4son.sync.p4_get_opened_files', return_value=[])
377
+ @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
378
+ def test_failing_hook_fails_the_gate(self, _dirty, _opened, _hooks):
379
+ self.assertFalse(sync_preflight('//ws', '/ws', '/invoked'))
380
+
381
+
382
+ class TestSyncSplitCommandMultiUser(unittest.TestCase):
383
+
384
+ def setUp(self):
385
+ patcher = mock.patch(
386
+ 'git_p4son.sync_split.resolve_depot_root',
387
+ return_value=ResolvedDepot(depot_root='//myclient',
388
+ client_spec=None))
389
+ patcher.start()
390
+ self.addCleanup(patcher.stop)
391
+ # The gate has its own tests; here it must simply not shell out.
392
+ gate = mock.patch('git_p4son.sync_split.sync_preflight',
393
+ return_value=True)
394
+ gate.start()
395
+ self.addCleanup(gate.stop)
396
+
397
+ def _args(self, **overrides):
398
+ values = dict(changelist=None, user=None, dry_run=False,
399
+ workspace_dir='/ws')
400
+ values.update(overrides)
401
+ return mock.Mock(**values)
402
+
224
403
  @mock.patch('git_p4son.sync_split.sync_command', return_value=0)
225
404
  @mock.patch('git_p4son.sync_split.get_submitted_changes')
226
405
  @mock.patch('git_p4son.sync_split.get_p4_user')
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