git-p4son 0.3.3__tar.gz → 0.3.4__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.3 → git_p4son-0.3.4}/PKG-INFO +9 -1
  2. {git_p4son-0.3.3 → git_p4son-0.3.4}/README.md +8 -0
  3. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/perforce.py +10 -0
  5. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/sync.py +141 -54
  6. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/sync_split.py +1 -1
  7. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son.egg-info/PKG-INFO +9 -1
  8. {git_p4son-0.3.3 → git_p4son-0.3.4}/pyproject.toml +1 -1
  9. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_perforce.py +30 -1
  10. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_sync.py +177 -8
  11. {git_p4son-0.3.3 → git_p4son-0.3.4}/LICENSE +0 -0
  12. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/__main__.py +0 -0
  13. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/alias.py +0 -0
  14. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/changelist_store.py +0 -0
  15. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/cli.py +0 -0
  16. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/common.py +0 -0
  17. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/complete.py +0 -0
  18. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/completions/_git-p4son +0 -0
  19. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/completions/git-p4son.bash +0 -0
  20. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/completions/git-p4son.ps1 +0 -0
  21. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/config.py +0 -0
  22. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/git.py +0 -0
  23. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/hooks.py +0 -0
  24. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/init.py +0 -0
  25. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/lib.py +0 -0
  26. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/list_changes.py +0 -0
  27. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/log.py +0 -0
  28. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/new.py +0 -0
  29. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/review.py +0 -0
  30. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/state.py +0 -0
  31. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son/update.py +0 -0
  32. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son.egg-info/SOURCES.txt +0 -0
  33. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son.egg-info/dependency_links.txt +0 -0
  34. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son.egg-info/entry_points.txt +0 -0
  35. {git_p4son-0.3.3 → git_p4son-0.3.4}/git_p4son.egg-info/top_level.txt +0 -0
  36. {git_p4son-0.3.3 → git_p4son-0.3.4}/setup.cfg +0 -0
  37. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_alias.py +0 -0
  38. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_changelist_store.py +0 -0
  39. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_cli.py +0 -0
  40. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_common.py +0 -0
  41. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_complete.py +0 -0
  42. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_config.py +0 -0
  43. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_git.py +0 -0
  44. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_hooks.py +0 -0
  45. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_init.py +0 -0
  46. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_lib_changelist.py +0 -0
  47. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_lib_edit.py +0 -0
  48. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_lib_review.py +0 -0
  49. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_list_changes.py +0 -0
  50. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_log.py +0 -0
  51. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_new.py +0 -0
  52. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_review.py +0 -0
  53. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_state.py +0 -0
  54. {git_p4son-0.3.3 → git_p4son-0.3.4}/tests/test_sync_split.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.3
3
+ Version: 0.3.4
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
@@ -231,6 +231,14 @@ be managed manually or via Perforce directly.
231
231
  After syncing, git-p4son prints a summary of how each writable file was handled, and instructs you to review and
232
232
  commit any files that need attention.
233
233
 
234
+ **Workspaces with `allwrite`** - When your client spec has the `allwrite` option, every file is writable by design,
235
+ so the read-only flag carries no information about local changes. git-p4son classifies files exactly as above (the
236
+ verdict comes from git, not from the flag), but leaves the write bit alone on files it finds unchanged: with
237
+ `noclobber` Perforce compares digests before refusing to overwrite a writable file, and unchanged files pass that
238
+ check. Only files with local changes are made read-only for the duration of the sync, since those are the ones
239
+ Perforce would otherwise refuse to overwrite. The same digest check applies to git-ignored files: unchanged ones sync
240
+ normally, and after the sync git-p4son lists the ones Perforce skipped because they were modified locally.
241
+
234
242
  ## Usage
235
243
 
236
244
  git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
@@ -208,6 +208,14 @@ be managed manually or via Perforce directly.
208
208
  After syncing, git-p4son prints a summary of how each writable file was handled, and instructs you to review and
209
209
  commit any files that need attention.
210
210
 
211
+ **Workspaces with `allwrite`** - When your client spec has the `allwrite` option, every file is writable by design,
212
+ so the read-only flag carries no information about local changes. git-p4son classifies files exactly as above (the
213
+ verdict comes from git, not from the flag), but leaves the write bit alone on files it finds unchanged: with
214
+ `noclobber` Perforce compares digests before refusing to overwrite a writable file, and unchanged files pass that
215
+ check. Only files with local changes are made read-only for the duration of the sync, since those are the ones
216
+ Perforce would otherwise refuse to overwrite. The same digest check applies to git-ignored files: unchanged ones sync
217
+ normally, and after the sync git-p4son lists the ones Perforce skipped because they were modified locally.
218
+
211
219
  ## Usage
212
220
 
213
221
  git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
@@ -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.3"
10
+ __version__ = "0.3.4"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -77,6 +77,10 @@ class P4ClientSpec:
77
77
  def clobber(self) -> bool:
78
78
  return 'clobber' in self.options
79
79
 
80
+ @property
81
+ def allwrite(self) -> bool:
82
+ return 'allwrite' in self.options
83
+
80
84
  @property
81
85
  def uses_crlf(self) -> bool:
82
86
  if self.line_end == 'win':
@@ -472,6 +476,12 @@ def is_binary_file_type(head_type: str) -> bool:
472
476
  return base_type in ('binary', 'ubinary')
473
477
 
474
478
 
479
+ def is_always_writable_file_type(head_type: str) -> bool:
480
+ """Check if a Perforce headType carries the +w (always writable) modifier."""
481
+ _, _, modifiers = head_type.partition('+')
482
+ return 'w' in modifiers
483
+
484
+
475
485
  @dataclass
476
486
  class P4SyncPreviewFile:
477
487
  """A file a sync would affect, from p4 sync -n output.
@@ -26,6 +26,7 @@ from .perforce import (
26
26
  get_client_spec,
27
27
  get_latest_changelist,
28
28
  get_writable_files,
29
+ is_always_writable_file_type,
29
30
  is_binary_file_type,
30
31
  p4_fstat_file_info,
31
32
  p4_get_opened_files,
@@ -101,6 +102,11 @@ class WritableSyncFileSet:
101
102
  """Writable files found during sync preview, classified."""
102
103
  changed: list[ChangedFile] = field(default_factory=list)
103
104
  ignored: list[str] = field(default_factory=list)
105
+ always_writable: list[str] = field(default_factory=list)
106
+ # Ignored files p4 actually refused to overwrite, known only after the
107
+ # sync. Without allwrite that is all of ignored; with it, only the
108
+ # locally modified ones.
109
+ not_synced: list[str] = field(default_factory=list)
104
110
 
105
111
 
106
112
  def _stage_temp_content(temp_root: str, rel_path: str, suffix: str,
@@ -154,12 +160,63 @@ def _stage_changed_file(meta: _ChangedFileMeta, pre_sync_head_commit: str,
154
160
  is_binary=is_binary, added_both=meta.added_both)
155
161
 
156
162
 
163
+ def _log_prepare_summary(result: WritableSyncFileSet, workspace_dir: str,
164
+ clobber: bool, unchanged_count: int = 0,
165
+ allwrite: bool = False) -> None:
166
+ """Log what the writable-file classification found."""
167
+ log.heading('Prepare sync summary')
168
+ if unchanged_count:
169
+ label = 'file' if unchanged_count == 1 else 'files'
170
+ log.success(
171
+ f'{unchanged_count} writable {label} unchanged, '
172
+ 'skipping merge')
173
+
174
+ if result.always_writable:
175
+ count = len(result.always_writable)
176
+ label = 'file is' if count == 1 else 'files are'
177
+ log.success(
178
+ f'{count} git-ignored {label} always writable (+w), '
179
+ 'p4 syncs them normally')
180
+
181
+ if result.changed:
182
+ count = len(result.changed)
183
+ label = 'file has' if count == 1 else 'files have'
184
+ log.warning(f'{count} {label} local changes, will merge after sync')
185
+ for cf in result.changed:
186
+ log.info(os.path.relpath(cf.filepath, workspace_dir))
187
+
188
+ if result.ignored:
189
+ count = len(result.ignored)
190
+ label = 'file' if count == 1 else 'files'
191
+ if clobber:
192
+ # These files are left writable, so with clobber enabled p4
193
+ # overwrites them during sync rather than refusing to.
194
+ log.warning(
195
+ f'{count} git-ignored writable {label} will be overwritten '
196
+ 'by p4 (clobber is enabled on the workspace)')
197
+ elif allwrite:
198
+ # With allwrite every file is writable, and p4 digest-compares
199
+ # before refusing to clobber one: unchanged files sync, modified
200
+ # ones are skipped. Which is which is only known after the sync,
201
+ # which reports the skipped ones, so they are not listed here.
202
+ log.info(
203
+ f'{count} git-ignored writable {label} will be synced '
204
+ 'unless modified locally')
205
+ return
206
+ else:
207
+ log.warning(
208
+ f'{count} git-ignored writable {label} will not be synced')
209
+ for f in result.ignored:
210
+ log.info(os.path.relpath(f, workspace_dir))
211
+
212
+
157
213
  def prepare_writable_files(preview_files: list[P4SyncPreviewFile],
158
214
  workspace_dir: str,
159
215
  pre_sync_head_commit: str,
160
216
  temp_root: str,
161
217
  uses_crlf: bool = False,
162
- clobber: bool = False) -> WritableSyncFileSet:
218
+ clobber: bool = False,
219
+ allwrite: bool = False) -> WritableSyncFileSet:
163
220
  """Check which preview files are writable on disk and prepare them for sync.
164
221
 
165
222
  For tracked writable files, queries Perforce for the file type (binary
@@ -172,7 +229,12 @@ def prepare_writable_files(preview_files: list[P4SyncPreviewFile],
172
229
  clobber reflects the workspace clobber option. git-ignored writable files
173
230
  are left writable and rely on p4 refusing to overwrite them, which only
174
231
  holds when clobber is off; with clobber on p4 overwrites them, so the
175
- reported outcome differs.
232
+ reported outcome differs. Ignored files whose Perforce type is +w
233
+ (always writable) are exempt: p4 overwrites those regardless, so they
234
+ sync normally and are reported separately.
235
+
236
+ allwrite reflects the workspace allwrite option, which narrows how many
237
+ files have their write bit stripped (see _clear_write_bits).
176
238
  """
177
239
  result = WritableSyncFileSet()
178
240
 
@@ -203,7 +265,27 @@ def prepare_writable_files(preview_files: list[P4SyncPreviewFile],
203
265
  result.ignored = [f for f in writable if f not in tracked_set]
204
266
  log.success(f'{len(tracked)} tracked, {len(result.ignored)} ignored')
205
267
 
268
+ if result.ignored:
269
+ # A file whose Perforce type carries the +w modifier is meant to be
270
+ # writable in the workspace. p4 overwrites it on sync without a
271
+ # clobber error, so it is not at risk of being skipped and must not
272
+ # be reported as such.
273
+ log.heading('Checking ignored files for the +w (always writable) type')
274
+ ignored_info = p4_fstat_file_info(result.ignored, workspace_dir)
275
+ always_writable = set()
276
+ for f in result.ignored:
277
+ info = ignored_info.get(f)
278
+ if info and is_always_writable_file_type(info.head_type):
279
+ always_writable.add(f)
280
+ result.always_writable = [f for f in result.ignored
281
+ if f in always_writable]
282
+ result.ignored = [f for f in result.ignored
283
+ if f not in always_writable]
284
+ log.success(f'{len(result.always_writable)} always writable (+w)')
285
+
206
286
  if not tracked:
287
+ _log_prepare_summary(result, workspace_dir, clobber,
288
+ allwrite=allwrite)
207
289
  return result
208
290
 
209
291
  # Pass 1: decide which files the user modified since their baseline by
@@ -212,10 +294,12 @@ def prepare_writable_files(preview_files: list[P4SyncPreviewFile],
212
294
  # one cat-file); with hundreds of writable files after a branch switch,
213
295
  # per-file process spawning dominated the entire sync.
214
296
  log.heading('Detecting modified tracked writable files')
215
- for f in tracked:
216
- # Make read-only regardless of whether changed or not
217
- mode = os.stat(f).st_mode
218
- os.chmod(f, mode & ~stat.S_IWUSR)
297
+ if not allwrite:
298
+ # Without allwrite, noclobber makes p4 refuse to overwrite any
299
+ # writable file, so every tracked file goes read-only whether it
300
+ # changed or not. The modified ones get their local content back
301
+ # from the post-sync merge.
302
+ _clear_write_bits(tracked)
219
303
 
220
304
  rel_paths = {f: os.path.relpath(f, workspace_dir) for f in tracked}
221
305
  candidates = [f for f in tracked if f not in added_upstream]
@@ -263,6 +347,15 @@ def prepare_writable_files(preview_files: list[P4SyncPreviewFile],
263
347
 
264
348
  log.success(f'{len(metas)} changed, {unchanged_count} unchanged')
265
349
 
350
+ if allwrite:
351
+ # An allwrite workspace is writable by design, so the write bit is
352
+ # left alone on files p4 will overwrite cleanly: with noclobber it
353
+ # digest-compares first and these still match the have revision,
354
+ # and with clobber it overwrites regardless. Only the modified
355
+ # files, the ones noclobber would refuse on, are stripped; the
356
+ # post-sync merge makes them writable again.
357
+ _clear_write_bits([m.filepath for m in metas])
358
+
266
359
  # Pass 2: query Perforce for file type only on the changed subset. The
267
360
  # binary verdict must be known before staging so text content can be
268
361
  # written once in the workspace line ending (its only authoritative
@@ -283,37 +376,17 @@ def prepare_writable_files(preview_files: list[P4SyncPreviewFile],
283
376
  is_binary, uses_crlf))
284
377
  log.success('')
285
378
 
286
- # Log what we found
287
- log.heading('Prepare sync summary')
288
- if unchanged_count:
289
- label = 'file' if unchanged_count == 1 else 'files'
290
- log.success(
291
- f'{unchanged_count} writable {label} unchanged, '
292
- 'skipping merge')
379
+ _log_prepare_summary(result, workspace_dir, clobber, unchanged_count,
380
+ allwrite)
381
+ return result
293
382
 
294
- if result.changed:
295
- count = len(result.changed)
296
- label = 'file has' if count == 1 else 'files have'
297
- log.warning(f'{count} {label} local changes, will merge after sync')
298
- for cf in result.changed:
299
- log.info(os.path.relpath(cf.filepath, workspace_dir))
300
383
 
301
- if result.ignored:
302
- count = len(result.ignored)
303
- label = 'file' if count == 1 else 'files'
304
- if clobber:
305
- # These files are left writable, so with clobber enabled p4
306
- # overwrites them during sync rather than refusing to.
307
- log.warning(
308
- f'{count} git-ignored writable {label} will be overwritten '
309
- 'by p4 (clobber is enabled on the workspace)')
310
- else:
311
- log.warning(
312
- f'{count} git-ignored writable {label} will not be synced')
313
- for f in result.ignored:
314
- log.info(os.path.relpath(f, workspace_dir))
315
-
316
- return result
384
+ def _clear_write_bits(filepaths: list[str]) -> None:
385
+ """Remove user write permission from each file, making p4 willing to
386
+ overwrite it."""
387
+ for filepath in filepaths:
388
+ mode = os.stat(filepath).st_mode
389
+ os.chmod(filepath, mode & ~stat.S_IWUSR)
317
390
 
318
391
 
319
392
  def _make_writable(filepath: str) -> None:
@@ -464,12 +537,12 @@ def _merge_changed_files(changed_files: list[ChangedFile],
464
537
 
465
538
  def p4_sync(changelist: int, label: str, depot_root: str,
466
539
  workspace_dir: str,
467
- expected_clobber: set[str] | None = None) -> None:
540
+ expected_clobber: set[str] | None = None) -> list[str]:
468
541
  """Sync files from Perforce.
469
542
 
470
543
  If expected_clobber is provided, clobber errors for those files are
471
- tolerated (they are git-ignored writable files). Unexpected clobber
472
- errors cause a raise.
544
+ tolerated (they are git-ignored writable files) and the files are
545
+ returned. Unexpected clobber errors cause a raise.
473
546
  """
474
547
  log.heading(f'Syncing to {label} CL ({changelist})')
475
548
 
@@ -481,6 +554,7 @@ def p4_sync(changelist: int, label: str, depot_root: str,
481
554
  if result.elapsed:
482
555
  log.elapsed(result.elapsed)
483
556
  log.success(output_processor.get_summary())
557
+ return []
484
558
  except RunError as e:
485
559
  writable_files = get_writable_files(e.stderr)
486
560
  if not writable_files:
@@ -499,6 +573,7 @@ def p4_sync(changelist: int, label: str, depot_root: str,
499
573
  log.warning(
500
574
  f'{len(writable_files)} expected clobber errors (git-ignored files)'
501
575
  )
576
+ return writable_files
502
577
 
503
578
 
504
579
  def _handle_clobber_warning(clobber: bool, workspace_dir: str) -> bool:
@@ -601,7 +676,8 @@ def sync_preflight(depot_root: str, workspace_dir: str, invocation_dir: str,
601
676
 
602
677
  def _sync_pass(changelist: int, label: str, depot_root: str,
603
678
  workspace_dir: str, pre_sync_head_commit: str, temp_root: str,
604
- uses_crlf: bool, clobber: bool) -> WritableSyncFileSet:
679
+ uses_crlf: bool, clobber: bool,
680
+ allwrite: bool) -> WritableSyncFileSet:
605
681
  """Run one sync pass: preview, prepare writable files, and p4 sync.
606
682
 
607
683
  Returns the classified writable file set. p4 sync is skipped when the
@@ -610,10 +686,11 @@ def _sync_pass(changelist: int, label: str, depot_root: str,
610
686
  preview = p4_sync_preview(changelist, depot_root, workspace_dir)
611
687
  prep = prepare_writable_files(preview, workspace_dir, pre_sync_head_commit,
612
688
  temp_root, uses_crlf=uses_crlf,
613
- clobber=clobber)
689
+ clobber=clobber, allwrite=allwrite)
614
690
  if preview:
615
- p4_sync(changelist, label, depot_root, workspace_dir,
616
- expected_clobber=set(prep.ignored))
691
+ prep.not_synced = p4_sync(changelist, label, depot_root,
692
+ workspace_dir,
693
+ expected_clobber=set(prep.ignored))
617
694
  return prep
618
695
 
619
696
 
@@ -774,6 +851,7 @@ def sync_command(args: argparse.Namespace) -> int:
774
851
  # (client_spec was fetched above to resolve the depot root.)
775
852
  uses_crlf = bool(client_spec and client_spec.uses_crlf)
776
853
  clobber = bool(client_spec and client_spec.clobber)
854
+ allwrite = bool(client_spec and client_spec.allwrite)
777
855
 
778
856
  # Prompted before the preflight so declining here costs nothing: no
779
857
  # workspace queries, and no hooks fired for a sync that is abandoned.
@@ -797,12 +875,13 @@ def sync_command(args: argparse.Namespace) -> int:
797
875
  if resync_last_synced:
798
876
  _sync_pass(last_sync.changelist, LAST_SYNCED_LABEL, depot_root,
799
877
  workspace_dir, pre_sync_head_commit, temp_root,
800
- uses_crlf, clobber)
878
+ uses_crlf, clobber, allwrite)
801
879
  run_hooks('post-sync', workspace_dir, invocation_dir)
802
880
  return 0
803
881
 
804
882
  all_changed: list[ChangedFile] = []
805
883
  all_ignored: list[str] = []
884
+ all_not_synced: list[str] = []
806
885
  last_changelist = last_sync.changelist if last_sync else None
807
886
 
808
887
  # Catch-up pass to the last synced changelist. This makes locally
@@ -812,9 +891,10 @@ def sync_command(args: argparse.Namespace) -> int:
812
891
  if last_changelist is not None:
813
892
  prep = _sync_pass(last_changelist, LAST_SYNCED_LABEL,
814
893
  depot_root, workspace_dir, pre_sync_head_commit,
815
- temp_root, uses_crlf, clobber)
894
+ temp_root, uses_crlf, clobber, allwrite)
816
895
  all_changed.extend(prep.changed)
817
896
  all_ignored.extend(prep.ignored)
897
+ all_not_synced.extend(prep.not_synced)
818
898
 
819
899
  # Sync each target changelist in turn, committing pure Perforce state
820
900
  # for each. Local changes are merged back once at the very end so the
@@ -822,9 +902,10 @@ def sync_command(args: argparse.Namespace) -> int:
822
902
  for changelist, changelist_label in targets:
823
903
  prep = _sync_pass(changelist, changelist_label, depot_root,
824
904
  workspace_dir, pre_sync_head_commit, temp_root,
825
- uses_crlf, clobber)
905
+ uses_crlf, clobber, allwrite)
826
906
  all_changed.extend(prep.changed)
827
907
  all_ignored.extend(prep.ignored)
908
+ all_not_synced.extend(prep.not_synced)
828
909
 
829
910
  log.heading(f'Committing git changes for CL {changelist}')
830
911
  dirty_files = get_dirty_files(workspace_dir)
@@ -844,15 +925,21 @@ def sync_command(args: argparse.Namespace) -> int:
844
925
 
845
926
  # Report git-ignored writable files. Left writable, they are only
846
927
  # preserved because p4 refuses to clobber them; with clobber on p4
847
- # overwrites them during the sync instead.
848
- if all_ignored:
849
- if clobber:
850
- log.heading(
851
- 'Git-ignored writable files overwritten by p4 '
852
- '(clobber enabled)')
853
- else:
854
- log.heading('Files not synced (git-ignored and writable)')
855
- for f in sorted(set(all_ignored)):
928
+ # overwrites them during the sync instead. With allwrite p4 refuses
929
+ # only the locally modified ones, so report what it actually skipped.
930
+ if clobber:
931
+ reported = all_ignored
932
+ heading = ('Git-ignored writable files overwritten by p4 '
933
+ '(clobber enabled)')
934
+ elif allwrite:
935
+ reported = all_not_synced
936
+ heading = 'Files not synced (git-ignored and locally modified)'
937
+ else:
938
+ reported = all_ignored
939
+ heading = 'Files not synced (git-ignored and writable)'
940
+ if reported:
941
+ log.heading(heading)
942
+ for f in sorted(set(reported)):
856
943
  log.info(os.path.relpath(f, workspace_dir))
857
944
 
858
945
  run_hooks('post-sync', workspace_dir, invocation_dir)
@@ -145,7 +145,7 @@ def sync_split_command(args: argparse.Namespace) -> int:
145
145
  label = 'changelist' if len(matched) == 1 else 'changelists'
146
146
  log.success(f'{len(matched)} {label} to split out')
147
147
  else:
148
- log.warning('None found, syncing to the target changelist only')
148
+ log.success('None found, syncing to the target changelist only')
149
149
 
150
150
  targets = build_sync_targets(changes, users, last_synced, upper)
151
151
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.3
3
+ Version: 0.3.4
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
@@ -231,6 +231,14 @@ be managed manually or via Perforce directly.
231
231
  After syncing, git-p4son prints a summary of how each writable file was handled, and instructs you to review and
232
232
  commit any files that need attention.
233
233
 
234
+ **Workspaces with `allwrite`** - When your client spec has the `allwrite` option, every file is writable by design,
235
+ so the read-only flag carries no information about local changes. git-p4son classifies files exactly as above (the
236
+ verdict comes from git, not from the flag), but leaves the write bit alone on files it finds unchanged: with
237
+ `noclobber` Perforce compares digests before refusing to overwrite a writable file, and unchanged files pass that
238
+ check. Only files with local changes are made read-only for the duration of the sync, since those are the ones
239
+ Perforce would otherwise refuse to overwrite. The same digest check applies to git-ignored files: unchanged ones sync
240
+ normally, and after the sync git-p4son lists the ones Perforce skipped because they were modified locally.
241
+
234
242
  ## Usage
235
243
 
236
244
  git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "git-p4son"
7
- version = "0.3.3"
7
+ version = "0.3.4"
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"
@@ -5,7 +5,8 @@ from unittest import mock
5
5
 
6
6
  from git_p4son.perforce import (
7
7
  P4ClientSpec, P4FileInfo, P4SyncPreviewFile, get_client_spec,
8
- get_p4_user, get_submitted_changes, is_binary_file_type,
8
+ get_p4_user, get_submitted_changes, is_always_writable_file_type,
9
+ is_binary_file_type,
9
10
  p4_fstat_file_info, p4_sync_preview, parse_ztag_multi_output,
10
11
  parse_ztag_output,
11
12
  )
@@ -102,6 +103,20 @@ class TestP4ClientSpec(unittest.TestCase):
102
103
  line_end='local')
103
104
  self.assertTrue(spec.clobber)
104
105
 
106
+ def test_allwrite_enabled(self):
107
+ spec = P4ClientSpec(
108
+ name='ws', root='/ws',
109
+ options=['allwrite', 'noclobber', 'nocompress'], stream=None,
110
+ line_end='local')
111
+ self.assertTrue(spec.allwrite)
112
+
113
+ def test_allwrite_disabled(self):
114
+ spec = P4ClientSpec(
115
+ name='ws', root='/ws',
116
+ options=['noallwrite', 'noclobber', 'nocompress'], stream=None,
117
+ line_end='local')
118
+ self.assertFalse(spec.allwrite)
119
+
105
120
  def test_clobber_disabled(self):
106
121
  spec = P4ClientSpec(
107
122
  name='ws', root='/ws',
@@ -195,6 +210,20 @@ class TestIsBinaryFileType(unittest.TestCase):
195
210
  self.assertTrue(is_binary_file_type('ubinary+x'))
196
211
 
197
212
 
213
+ class TestIsAlwaysWritableFileType(unittest.TestCase):
214
+ def test_without_w_modifier(self):
215
+ self.assertFalse(is_always_writable_file_type('text'))
216
+ self.assertFalse(is_always_writable_file_type('binary'))
217
+ self.assertFalse(is_always_writable_file_type('binary+x'))
218
+ self.assertFalse(is_always_writable_file_type('text+kx'))
219
+
220
+ def test_with_w_modifier(self):
221
+ self.assertTrue(is_always_writable_file_type('text+w'))
222
+ self.assertTrue(is_always_writable_file_type('binary+w'))
223
+ self.assertTrue(is_always_writable_file_type('binary+Swl'))
224
+ self.assertTrue(is_always_writable_file_type('binary+xw'))
225
+
226
+
198
227
  class TestP4FstatFileInfo(unittest.TestCase):
199
228
  @mock.patch('git_p4son.perforce.run')
200
229
  def test_returns_head_type(self, mock_run):
@@ -433,8 +433,15 @@ class TestP4Sync(unittest.TestCase):
433
433
  'p4 sync failed', returncode=1,
434
434
  stderr=["Can't clobber writable file /ws/a.txt",
435
435
  "Can't clobber writable file /ws/b.txt"])
436
- p4_sync(12345, 'test', '//myclient', '/ws',
437
- expected_clobber={'/ws/a.txt', '/ws/b.txt'})
436
+ refused = p4_sync(12345, 'test', '//myclient', '/ws',
437
+ expected_clobber={'/ws/a.txt', '/ws/b.txt'})
438
+ self.assertEqual(sorted(refused), ['/ws/a.txt', '/ws/b.txt'])
439
+
440
+ @mock.patch('git_p4son.sync.run_with_output')
441
+ def test_clean_sync_refuses_nothing(self, mock_rwo):
442
+ mock_rwo.return_value = mock.Mock(elapsed=None)
443
+ self.assertEqual(
444
+ p4_sync(12345, 'test', '//myclient', '/ws'), [])
438
445
 
439
446
  @mock.patch('git_p4son.sync.run_with_output')
440
447
  def test_unexpected_clobber_raises(self, mock_rwo):
@@ -540,6 +547,57 @@ class TestPrepareWritableFiles(unittest.TestCase):
540
547
  mode = os.stat(path).st_mode
541
548
  self.assertFalse(mode & stat.S_IWUSR)
542
549
 
550
+ @mock.patch('git_p4son.sync.get_blob_oids',
551
+ return_value={('head123', 'a.txt'): 'same_oid',
552
+ ('sync456', 'a.txt'): 'same_oid'})
553
+ @mock.patch('git_p4son.sync.get_file_at_commit')
554
+ @mock.patch('git_p4son.sync.find_base_commits',
555
+ return_value={'a.txt': 'sync456'})
556
+ @mock.patch('git_p4son.sync.p4_fstat_file_info')
557
+ @mock.patch('git_p4son.sync.get_tracked_files',
558
+ side_effect=lambda paths, ws: set(paths))
559
+ def test_allwrite_keeps_unchanged_file_writable(
560
+ self, _tracked, mock_fstat, _find_base, _get_file, _oids):
561
+ """An allwrite workspace is writable by design, and p4 overwrites
562
+ an unchanged file regardless of clobber, so the write bit stays."""
563
+ from git_p4son.perforce import P4FileInfo
564
+ with tempfile.TemporaryDirectory() as ws:
565
+ path = self._make_file(ws, 'a.txt')
566
+ mock_fstat.return_value = {path: P4FileInfo(head_type='text')}
567
+
568
+ result = prepare_writable_files([_upd(path)], ws, 'head123',
569
+ self.temp_root, allwrite=True)
570
+
571
+ self.assertEqual(result.changed, [])
572
+ mode = os.stat(path).st_mode
573
+ self.assertTrue(mode & stat.S_IWUSR)
574
+
575
+ @mock.patch('git_p4son.sync.get_blob_oids')
576
+ @mock.patch('git_p4son.sync.get_file_at_commit',
577
+ return_value=b'head content')
578
+ @mock.patch('git_p4son.sync.find_base_commits',
579
+ return_value={'a.txt': 'sync456'})
580
+ @mock.patch('git_p4son.sync.p4_fstat_file_info')
581
+ @mock.patch('git_p4son.sync.get_tracked_files',
582
+ side_effect=lambda paths, ws: set(paths))
583
+ def test_allwrite_makes_changed_file_read_only(
584
+ self, _tracked, mock_fstat, _find_base, _get_file, mock_oids):
585
+ """With allwrite plus noclobber p4 digest-compares and would refuse
586
+ on exactly the modified files, so those still go read-only."""
587
+ from git_p4son.perforce import P4FileInfo
588
+ with tempfile.TemporaryDirectory() as ws:
589
+ path = self._make_file(ws, 'a.txt')
590
+ mock_fstat.return_value = {path: P4FileInfo(head_type='text')}
591
+ mock_oids.return_value = {('head123', 'a.txt'): 'oid_head',
592
+ ('sync456', 'a.txt'): 'oid_base'}
593
+
594
+ result = prepare_writable_files([_upd(path)], ws, 'head123',
595
+ self.temp_root, allwrite=True)
596
+
597
+ self.assertEqual([cf.filepath for cf in result.changed], [path])
598
+ mode = os.stat(path).st_mode
599
+ self.assertFalse(mode & stat.S_IWUSR)
600
+
543
601
  @mock.patch('git_p4son.sync.get_file_at_commit',
544
602
  return_value=b'head content')
545
603
  @mock.patch('git_p4son.sync.find_base_commits',
@@ -726,33 +784,86 @@ class TestPrepareWritableFiles(unittest.TestCase):
726
784
  path = self._make_file(ws, 'build.log')
727
785
 
728
786
  with mock.patch('git_p4son.sync.get_tracked_files',
729
- return_value=set()):
787
+ return_value=set()), \
788
+ mock.patch('git_p4son.sync.p4_fstat_file_info',
789
+ return_value={}):
730
790
  result = prepare_writable_files([_upd(path)], ws, 'head123',
731
791
  self.temp_root)
732
792
 
733
793
  self.assertEqual(result.changed, [])
734
794
  self.assertEqual(result.ignored, [path])
795
+ self.assertEqual(result.always_writable, [])
735
796
  mode = os.stat(path).st_mode
736
797
  self.assertTrue(mode & stat.S_IWUSR)
737
798
 
738
- def _run_with_tracked_and_ignored(self, ws, clobber):
799
+ def test_always_writable_ignored_file_not_reported_as_unsynced(self):
800
+ """A git-ignored file whose Perforce type is +w is meant to be
801
+ writable; p4 overwrites it on sync, so no warning is due."""
802
+ from git_p4son.perforce import P4FileInfo
803
+ with tempfile.TemporaryDirectory() as ws:
804
+ path = self._make_file(ws, 'Tool.exe')
805
+
806
+ with mock.patch('git_p4son.sync.get_tracked_files',
807
+ return_value=set()), \
808
+ mock.patch(
809
+ 'git_p4son.sync.p4_fstat_file_info',
810
+ return_value={
811
+ path: P4FileInfo(head_type='binary+w')}), \
812
+ mock.patch('git_p4son.sync.log') as mock_log:
813
+ result = prepare_writable_files([_upd(path)], ws, 'head123',
814
+ self.temp_root)
815
+
816
+ self.assertEqual(result.ignored, [])
817
+ self.assertEqual(result.always_writable, [path])
818
+ warnings = ' '.join(
819
+ str(c.args[0]) for c in mock_log.warning.call_args_list)
820
+ self.assertNotIn('will not be synced', warnings)
821
+ mode = os.stat(path).st_mode
822
+ self.assertTrue(mode & stat.S_IWUSR)
823
+
824
+ def test_always_writable_files_excluded_from_expected_clobber(self):
825
+ """+w files must not be classified as ignored: a clobber error on
826
+ one is unexpected and should still surface."""
827
+ from git_p4son.perforce import P4FileInfo
828
+ with tempfile.TemporaryDirectory() as ws:
829
+ always = self._make_file(ws, 'Tool.exe')
830
+ ignored = self._make_file(ws, 'build.log')
831
+
832
+ with mock.patch('git_p4son.sync.get_tracked_files',
833
+ return_value=set()), \
834
+ mock.patch(
835
+ 'git_p4son.sync.p4_fstat_file_info',
836
+ return_value={
837
+ always: P4FileInfo(head_type='binary+w'),
838
+ ignored: P4FileInfo(head_type='text')}):
839
+ result = prepare_writable_files(
840
+ [_upd(always), _upd(ignored)], ws, 'head123',
841
+ self.temp_root)
842
+
843
+ self.assertEqual(result.ignored, [ignored])
844
+ self.assertEqual(result.always_writable, [always])
845
+
846
+ def _run_with_tracked_and_ignored(self, ws, clobber, allwrite=False,
847
+ level='warning'):
739
848
  """Prepare one unchanged tracked file plus one ignored file so the
740
849
  summary block (which reports ignored files) is reached, and return
741
- the concatenated warning messages."""
850
+ the concatenated messages logged at level."""
742
851
  tracked = self._make_file(ws, 'a.txt')
743
852
  ignored = self._make_file(ws, 'build.log')
744
853
  with mock.patch('git_p4son.sync.get_tracked_files',
745
854
  return_value={tracked}), \
855
+ mock.patch('git_p4son.sync.p4_fstat_file_info',
856
+ return_value={}), \
746
857
  mock.patch('git_p4son.sync.find_base_commits',
747
858
  return_value={'a.txt': 'head123'}), \
748
859
  mock.patch('git_p4son.sync.get_blob_oids', return_value={}), \
749
860
  mock.patch('git_p4son.sync.log') as mock_log:
750
861
  result = prepare_writable_files(
751
862
  [_upd(tracked), _upd(ignored)], ws, 'head123',
752
- self.temp_root, clobber=clobber)
863
+ self.temp_root, clobber=clobber, allwrite=allwrite)
753
864
  self.assertEqual(result.ignored, [ignored])
754
- return ' '.join(
755
- str(c.args[0]) for c in mock_log.warning.call_args_list)
865
+ return ' '.join(str(c.args[0]) for c in
866
+ getattr(mock_log, level).call_args_list)
756
867
 
757
868
  def test_ignored_message_without_clobber(self):
758
869
  with tempfile.TemporaryDirectory() as ws:
@@ -768,6 +879,26 @@ class TestPrepareWritableFiles(unittest.TestCase):
768
879
  self.assertIn('overwritten', warnings)
769
880
  self.assertNotIn('will not be synced', warnings)
770
881
 
882
+ def test_ignored_message_with_allwrite(self):
883
+ """With allwrite p4 digest-compares before refusing to clobber, so
884
+ unchanged ignored files do sync: the summary must not claim none
885
+ will, and cannot yet say which will not."""
886
+ with tempfile.TemporaryDirectory() as ws:
887
+ warnings = self._run_with_tracked_and_ignored(
888
+ ws, clobber=False, allwrite=True)
889
+ self.assertNotIn('will not be synced', warnings)
890
+ infos = self._run_with_tracked_and_ignored(
891
+ ws, clobber=False, allwrite=True, level='info')
892
+ self.assertIn('unless modified locally', infos)
893
+ self.assertNotIn('build.log', infos)
894
+
895
+ def test_ignored_message_with_allwrite_and_clobber(self):
896
+ """clobber overwrites regardless, so it wins over allwrite."""
897
+ with tempfile.TemporaryDirectory() as ws:
898
+ warnings = self._run_with_tracked_and_ignored(
899
+ ws, clobber=True, allwrite=True)
900
+ self.assertIn('overwritten', warnings)
901
+
771
902
  def test_nonexistent_files_skipped(self):
772
903
  result = prepare_writable_files(
773
904
  [_upd('/ws/noexist.txt')], '/ws', 'head123', self.temp_root)
@@ -816,6 +947,43 @@ class TestSyncCommand(unittest.TestCase):
816
947
  rc = sync_command(args)
817
948
  self.assertEqual(rc, 0)
818
949
 
950
+ @mock.patch('git_p4son.sync.log')
951
+ @mock.patch('git_p4son.sync._merge_changed_files')
952
+ @mock.patch('git_p4son.sync.commit')
953
+ @mock.patch('git_p4son.sync.add_all_files')
954
+ @mock.patch('git_p4son.sync.get_dirty_files', return_value=[])
955
+ @mock.patch('git_p4son.sync.p4_sync', return_value=['/ws/edited.log'])
956
+ @mock.patch('git_p4son.sync.prepare_writable_files')
957
+ @mock.patch('git_p4son.sync.p4_sync_preview',
958
+ return_value=[_upd('/ws/edited.log')])
959
+ @mock.patch('git_p4son.sync.get_head_commit', return_value='def456')
960
+ @mock.patch('git_p4son.sync.git_last_sync')
961
+ @mock.patch('git_p4son.sync.p4_get_opened_files', return_value=[])
962
+ @mock.patch('git_p4son.sync.get_client_spec')
963
+ @mock.patch('git_p4son.sync.get_depot_root', return_value='//myclient')
964
+ def test_allwrite_reports_only_refused_ignored_files(
965
+ self, _depot, mock_spec, _p4clean, mock_last_sync, _head,
966
+ _preview, mock_prep, _p4sync, _git_clean, _add, _commit, _merge,
967
+ mock_log):
968
+ """With allwrite p4 syncs unchanged ignored files and refuses only
969
+ the modified ones, so the report lists what it actually skipped."""
970
+ spec = mock.Mock(uses_crlf=False, clobber=False, allwrite=True)
971
+ spec.name = 'myclient'
972
+ mock_spec.return_value = spec
973
+ mock_last_sync.return_value = self._last_sync
974
+ mock_prep.return_value = WritableSyncFileSet(
975
+ ignored=['/ws/pristine.log', '/ws/edited.log'])
976
+ args = mock.Mock(changelist=['12345'], force=False,
977
+ workspace_dir='/ws')
978
+ self.assertEqual(sync_command(args), 0)
979
+
980
+ headings = [str(c.args[0]) for c in mock_log.heading.call_args_list]
981
+ self.assertIn('Files not synced (git-ignored and locally modified)',
982
+ headings)
983
+ infos = [str(c.args[0]) for c in mock_log.info.call_args_list]
984
+ self.assertIn('edited.log', infos)
985
+ self.assertNotIn('pristine.log', infos)
986
+
819
987
  @mock.patch('git_p4son.sync.get_depot_root', return_value=None)
820
988
  def test_no_depot_root_aborts(self, _depot):
821
989
  args = mock.Mock(changelist=['100'], force=False, workspace_dir='/ws')
@@ -843,6 +1011,7 @@ class TestSyncCommand(unittest.TestCase):
843
1011
  spec.name = 'real-client'
844
1012
  spec.uses_crlf = False
845
1013
  spec.clobber = False
1014
+ spec.allwrite = False
846
1015
  mock_spec.return_value = spec
847
1016
  mock_last_sync.return_value = self._last_sync
848
1017
  mock_prep.return_value = self._empty_prep()
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