git-p4son 0.3.4__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 (63) hide show
  1. {git_p4son-0.3.4 → git_p4son-0.4.1}/PKG-INFO +79 -5
  2. {git_p4son-0.3.4 → git_p4son-0.4.1}/README.md +78 -4
  3. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/changelist_store.py +2 -0
  5. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/cli.py +49 -2
  6. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/common.py +53 -9
  7. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/complete.py +12 -9
  8. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/config.py +21 -16
  9. git_p4son-0.4.1/git_p4son/depot.py +62 -0
  10. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/git.py +36 -19
  11. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/init.py +50 -6
  12. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/lib.py +77 -1
  13. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/log.py +32 -4
  14. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/new.py +20 -1
  15. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/perforce.py +75 -8
  16. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/review.py +2 -0
  17. git_p4son-0.4.1/git_p4son/state.py +38 -0
  18. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/sync.py +44 -50
  19. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/sync_split.py +5 -3
  20. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/update.py +20 -1
  21. git_p4son-0.4.1/git_p4son/writable.py +155 -0
  22. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son.egg-info/PKG-INFO +79 -5
  23. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son.egg-info/SOURCES.txt +7 -1
  24. {git_p4son-0.3.4 → git_p4son-0.4.1}/pyproject.toml +1 -1
  25. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_changelist_store.py +7 -0
  26. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_cli.py +27 -0
  27. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_common.py +67 -0
  28. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_complete.py +9 -1
  29. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_config.py +42 -33
  30. git_p4son-0.4.1/tests/test_depot.py +42 -0
  31. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_git.py +54 -1
  32. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_init.py +94 -0
  33. git_p4son-0.4.1/tests/test_lib_revert.py +251 -0
  34. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_lib_review.py +10 -20
  35. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_log.py +60 -0
  36. git_p4son-0.4.1/tests/test_new.py +121 -0
  37. git_p4son-0.4.1/tests/test_state.py +39 -0
  38. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_sync.py +247 -43
  39. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_sync_split.py +24 -6
  40. git_p4son-0.4.1/tests/test_update.py +85 -0
  41. git_p4son-0.4.1/tests/test_writable.py +239 -0
  42. git_p4son-0.3.4/git_p4son/state.py +0 -56
  43. git_p4son-0.3.4/tests/test_new.py +0 -56
  44. git_p4son-0.3.4/tests/test_state.py +0 -64
  45. {git_p4son-0.3.4 → git_p4son-0.4.1}/LICENSE +0 -0
  46. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/__main__.py +0 -0
  47. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/alias.py +0 -0
  48. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/completions/_git-p4son +0 -0
  49. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/completions/git-p4son.bash +0 -0
  50. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/completions/git-p4son.ps1 +0 -0
  51. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/hooks.py +0 -0
  52. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son/list_changes.py +0 -0
  53. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son.egg-info/dependency_links.txt +0 -0
  54. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son.egg-info/entry_points.txt +0 -0
  55. {git_p4son-0.3.4 → git_p4son-0.4.1}/git_p4son.egg-info/top_level.txt +0 -0
  56. {git_p4son-0.3.4 → git_p4son-0.4.1}/setup.cfg +0 -0
  57. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_alias.py +0 -0
  58. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_hooks.py +0 -0
  59. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_lib_changelist.py +0 -0
  60. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_lib_edit.py +0 -0
  61. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_list_changes.py +0 -0
  62. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_perforce.py +0 -0
  63. {git_p4son-0.3.4 → git_p4son-0.4.1}/tests/test_review.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.3.4
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
@@ -239,10 +239,13 @@ check. Only files with local changes are made read-only for the duration of the
239
239
  Perforce would otherwise refuse to overwrite. The same digest check applies to git-ignored files: unchanged ones sync
240
240
  normally, and after the sync git-p4son lists the ones Perforce skipped because they were modified locally.
241
241
 
242
+ **Writable mode** - With [writable mode](#writable-command) on, the files a sync touches are made writable again after
243
+ the sync if git tracks them. The sync itself works as described above, since the workspace is not `allwrite`.
244
+
242
245
  ## Usage
243
246
 
244
- git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
245
- `alias`, and `completion`.
247
+ git-p4son provides ten commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
248
+ `alias`, `writable`, and `completion`.
246
249
 
247
250
  To see help for any command, use `-h`:
248
251
 
@@ -274,6 +277,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
274
277
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
275
278
  `.git-p4son/config.toml` and used by all subsequent commands.
276
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
+
277
283
  The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
278
284
  `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
279
285
  the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
@@ -284,12 +290,15 @@ The `.gitignore` is set up using this priority:
284
290
  - If `.p4ignore` exists, it is copied to `.gitignore` as a starting point
285
291
  - Otherwise, an empty `.gitignore` is created
286
292
 
293
+ `init` also asks whether to turn on [writable mode](#writable-command), defaulting to the current setting. It only
294
+ saves the answer; run `git p4son writable apply` once your files are committed to update their permissions.
295
+
287
296
  ### Sync Command
288
297
 
289
298
  Sync local git repository with a Perforce workspace:
290
299
 
291
300
  ```sh
292
- git p4son sync [changelist ...] [--force]
301
+ git p4son sync [changelist ...] [--force] [--dry-run]
293
302
  ```
294
303
 
295
304
  **Arguments:**
@@ -300,6 +309,8 @@ git p4son sync [changelist ...] [--force]
300
309
 
301
310
  **Options:**
302
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.
303
314
 
304
315
  **Examples:**
305
316
  ```sh
@@ -310,6 +321,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
310
321
  git p4son sync 123 156 head # sync 123, 156, then the latest changelist
311
322
  git p4son sync last-synced
312
323
  git p4son sync 12345 --force
324
+ git p4son sync --dry-run # show the changelists a sync to latest would visit
313
325
  ```
314
326
 
315
327
  #### pre-sync hook
@@ -379,6 +391,10 @@ than the last synced changelist (use `git p4son sync --force` for that).
379
391
  Create a new Perforce changelist and add changed files to it. Description will contain an enumerated list of git commits since the base branch.
380
392
  Optionally creates a Swarm review.
381
393
 
394
+ Opening files requires a clean git workspace, untracked files included; `new` refuses to run otherwise. The check
395
+ is skipped with `--no-edit`. After opening files, unchanged ones are reverted (see
396
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)).
397
+
382
398
  ```sh
383
399
  git p4son new -m <message> [alias] [--base-branch BASE_BRANCH] [--force] [--dry-run] [--no-edit] [--no-alias]
384
400
  [--shelve] [--review]
@@ -414,7 +430,10 @@ Update an existing Perforce changelist description. Commits since the base branc
414
430
  entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
415
431
  range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
416
432
  it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
417
- edit.
433
+ edit, and reverts files that are no longer part of the git change (see
434
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)). Opening files requires a
435
+ clean git workspace, untracked files included; `update` refuses to run otherwise. The check is skipped with
436
+ `--no-edit`.
418
437
 
419
438
  ```sh
420
439
  git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
@@ -440,6 +459,27 @@ git p4son update 12345
440
459
  git p4son update myalias -b main
441
460
  ```
442
461
 
462
+ ### Keeping the changelist in step with git
463
+
464
+ `new` and `update` open every file changed since the base branch in the changelist. A file can later stop being
465
+ part of the change, for example when a later commit undoes an edit, or a file is added in one commit and deleted
466
+ in the next. Such files would otherwise stay opened as no-ops, so after opening files both commands revert them:
467
+
468
+ - Files opened for edit that are unchanged compared to the depot are reverted with `p4 revert -a`. Changed files
469
+ stay opened.
470
+ - Files opened for add that are missing from disk are reverted.
471
+ - Files opened for delete that git still has (deleted in one commit, restored in a later one) are reverted and
472
+ restored from git.
473
+
474
+ Only files git-p4son has authority over are touched: files tracked by git, and files opened for add that are
475
+ missing from disk. Anything else in the changelist, such as binaries that only exist in Perforce and were opened
476
+ by hand, is left alone even when unchanged. Files opened with other actions (`move/add`, `integrate`, ...) are
477
+ also left alone. This step is skipped with `--no-edit`.
478
+
479
+ Shelving (`--shelve`, `--review`) replaces the whole shelf with the changelist's opened files using
480
+ `p4 shelve -r -a leaveunchanged`, so the shelf never holds stale files, and unchanged files that are still opened
481
+ are left out of it.
482
+
443
483
  ### Review Command
444
484
 
445
485
  Automate the interactive rebase workflow for creating Swarm reviews. This command generates a rebase todo with `exec` lines that run `git p4son new --review` on the first commit and `git p4son update --shelve` on each subsequent commit, then opens it in your editor for review before executing.
@@ -583,6 +623,40 @@ In interactive mode, each alias is displayed in turn with a prompt:
583
623
  git p4son alias clean
584
624
  ```
585
625
 
626
+ ### Writable Command
627
+
628
+ Writable mode keeps git-tracked files writable, so you can edit them without running `p4 edit` first. git-p4son
629
+ opens changed files in Perforce itself when you run `new`, `update` or `review`. Git-ignored files, such as content
630
+ and other Perforce-only files, stay read-only and are checked out through Perforce as usual.
631
+
632
+ ```sh
633
+ git p4son writable # Show whether writable mode is on
634
+ git p4son writable enable # Turn writable mode on, then apply it
635
+ git p4son writable disable # Turn writable mode off, then apply it
636
+ git p4son writable apply # Make git-tracked files match the current mode
637
+ ```
638
+
639
+ `apply` makes every git-tracked file writable when the mode is on. When it's off, it makes them read-only, except
640
+ files opened in Perforce in any changelist. On a workspace with the `allwrite` option, making files read-only is
641
+ skipped, since the client spec wants every file writable. Git-ignored files are never changed.
642
+
643
+ The setting is stored in `.git-p4son/config.toml`:
644
+
645
+ ```toml
646
+ [core]
647
+ writable = true
648
+ ```
649
+
650
+ With the mode on, `sync` makes the files it synced writable again if git tracks them, since Perforce writes synced
651
+ files read-only. Files made read-only outside git-p4son, for example by submitting from P4V, `p4 revert`, or "Get
652
+ Latest" in P4V, stay read-only until a later sync touches them. Run `git p4son writable apply` to fix them right
653
+ away.
654
+
655
+ This replaces the `post-sync` hook some users run to make every tracked file writable after a sync.
656
+
657
+ Turning the mode off also makes tracked files whose Perforce type carries the `+w` (always writable) modifier
658
+ read-only. The next sync of such a file makes it writable again.
659
+
586
660
  ### Completion Command
587
661
 
588
662
  Print the path to a shell completion script:
@@ -216,10 +216,13 @@ check. Only files with local changes are made read-only for the duration of the
216
216
  Perforce would otherwise refuse to overwrite. The same digest check applies to git-ignored files: unchanged ones sync
217
217
  normally, and after the sync git-p4son lists the ones Perforce skipped because they were modified locally.
218
218
 
219
+ **Writable mode** - With [writable mode](#writable-command) on, the files a sync touches are made writable again after
220
+ the sync if git tracks them. The sync itself works as described above, since the workspace is not `allwrite`.
221
+
219
222
  ## Usage
220
223
 
221
- git-p4son provides nine commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
222
- `alias`, and `completion`.
224
+ git-p4son provides ten commands: `init`, `sync`, `sync-split`, `new`, `update`, `review`, `list-changes`,
225
+ `alias`, `writable`, and `completion`.
223
226
 
224
227
  To see help for any command, use `-h`:
225
228
 
@@ -251,6 +254,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
251
254
  workspace or just the directory's subtree where the git root is placed. The selection is saved in
252
255
  `.git-p4son/config.toml` and used by all subsequent commands.
253
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
+
254
260
  The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
255
261
  `root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
256
262
  the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
@@ -261,12 +267,15 @@ The `.gitignore` is set up using this priority:
261
267
  - If `.p4ignore` exists, it is copied to `.gitignore` as a starting point
262
268
  - Otherwise, an empty `.gitignore` is created
263
269
 
270
+ `init` also asks whether to turn on [writable mode](#writable-command), defaulting to the current setting. It only
271
+ saves the answer; run `git p4son writable apply` once your files are committed to update their permissions.
272
+
264
273
  ### Sync Command
265
274
 
266
275
  Sync local git repository with a Perforce workspace:
267
276
 
268
277
  ```sh
269
- git p4son sync [changelist ...] [--force]
278
+ git p4son sync [changelist ...] [--force] [--dry-run]
270
279
  ```
271
280
 
272
281
  **Arguments:**
@@ -277,6 +286,8 @@ git p4son sync [changelist ...] [--force]
277
286
 
278
287
  **Options:**
279
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.
280
291
 
281
292
  **Examples:**
282
293
  ```sh
@@ -287,6 +298,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
287
298
  git p4son sync 123 156 head # sync 123, 156, then the latest changelist
288
299
  git p4son sync last-synced
289
300
  git p4son sync 12345 --force
301
+ git p4son sync --dry-run # show the changelists a sync to latest would visit
290
302
  ```
291
303
 
292
304
  #### pre-sync hook
@@ -356,6 +368,10 @@ than the last synced changelist (use `git p4son sync --force` for that).
356
368
  Create a new Perforce changelist and add changed files to it. Description will contain an enumerated list of git commits since the base branch.
357
369
  Optionally creates a Swarm review.
358
370
 
371
+ Opening files requires a clean git workspace, untracked files included; `new` refuses to run otherwise. The check
372
+ is skipped with `--no-edit`. After opening files, unchanged ones are reverted (see
373
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)).
374
+
359
375
  ```sh
360
376
  git p4son new -m <message> [alias] [--base-branch BASE_BRANCH] [--force] [--dry-run] [--no-edit] [--no-alias]
361
377
  [--shelve] [--review]
@@ -391,7 +407,10 @@ Update an existing Perforce changelist description. Commits since the base branc
391
407
  entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
392
408
  range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
393
409
  it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
394
- edit.
410
+ edit, and reverts files that are no longer part of the git change (see
411
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)). Opening files requires a
412
+ clean git workspace, untracked files included; `update` refuses to run otherwise. The check is skipped with
413
+ `--no-edit`.
395
414
 
396
415
  ```sh
397
416
  git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
@@ -417,6 +436,27 @@ git p4son update 12345
417
436
  git p4son update myalias -b main
418
437
  ```
419
438
 
439
+ ### Keeping the changelist in step with git
440
+
441
+ `new` and `update` open every file changed since the base branch in the changelist. A file can later stop being
442
+ part of the change, for example when a later commit undoes an edit, or a file is added in one commit and deleted
443
+ in the next. Such files would otherwise stay opened as no-ops, so after opening files both commands revert them:
444
+
445
+ - Files opened for edit that are unchanged compared to the depot are reverted with `p4 revert -a`. Changed files
446
+ stay opened.
447
+ - Files opened for add that are missing from disk are reverted.
448
+ - Files opened for delete that git still has (deleted in one commit, restored in a later one) are reverted and
449
+ restored from git.
450
+
451
+ Only files git-p4son has authority over are touched: files tracked by git, and files opened for add that are
452
+ missing from disk. Anything else in the changelist, such as binaries that only exist in Perforce and were opened
453
+ by hand, is left alone even when unchanged. Files opened with other actions (`move/add`, `integrate`, ...) are
454
+ also left alone. This step is skipped with `--no-edit`.
455
+
456
+ Shelving (`--shelve`, `--review`) replaces the whole shelf with the changelist's opened files using
457
+ `p4 shelve -r -a leaveunchanged`, so the shelf never holds stale files, and unchanged files that are still opened
458
+ are left out of it.
459
+
420
460
  ### Review Command
421
461
 
422
462
  Automate the interactive rebase workflow for creating Swarm reviews. This command generates a rebase todo with `exec` lines that run `git p4son new --review` on the first commit and `git p4son update --shelve` on each subsequent commit, then opens it in your editor for review before executing.
@@ -560,6 +600,40 @@ In interactive mode, each alias is displayed in turn with a prompt:
560
600
  git p4son alias clean
561
601
  ```
562
602
 
603
+ ### Writable Command
604
+
605
+ Writable mode keeps git-tracked files writable, so you can edit them without running `p4 edit` first. git-p4son
606
+ opens changed files in Perforce itself when you run `new`, `update` or `review`. Git-ignored files, such as content
607
+ and other Perforce-only files, stay read-only and are checked out through Perforce as usual.
608
+
609
+ ```sh
610
+ git p4son writable # Show whether writable mode is on
611
+ git p4son writable enable # Turn writable mode on, then apply it
612
+ git p4son writable disable # Turn writable mode off, then apply it
613
+ git p4son writable apply # Make git-tracked files match the current mode
614
+ ```
615
+
616
+ `apply` makes every git-tracked file writable when the mode is on. When it's off, it makes them read-only, except
617
+ files opened in Perforce in any changelist. On a workspace with the `allwrite` option, making files read-only is
618
+ skipped, since the client spec wants every file writable. Git-ignored files are never changed.
619
+
620
+ The setting is stored in `.git-p4son/config.toml`:
621
+
622
+ ```toml
623
+ [core]
624
+ writable = true
625
+ ```
626
+
627
+ With the mode on, `sync` makes the files it synced writable again if git tracks them, since Perforce writes synced
628
+ files read-only. Files made read-only outside git-p4son, for example by submitting from P4V, `p4 revert`, or "Get
629
+ Latest" in P4V, stay read-only until a later sync touches them. Run `git p4son writable apply` to fix them right
630
+ away.
631
+
632
+ This replaces the `post-sync` hook some users run to make every tracked file writable after a sync.
633
+
634
+ Turning the mode off also makes tracked files whose Perforce type carries the `+w` (always writable) modifier
635
+ read-only. The next sync of such a file makes it writable again.
636
+
563
637
  ### Completion Command
564
638
 
565
639
  Print the path to a shell completion script:
@@ -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.4"
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)
@@ -21,6 +21,7 @@ from .common import CommandError, RunError, branch_to_alias
21
21
  from .git import get_current_branch, get_head_subject, get_workspace_dir
22
22
  from .log import log
23
23
  from .complete import run_complete
24
+ from .writable import writable_command
24
25
 
25
26
 
26
27
  def create_parser() -> argparse.ArgumentParser:
@@ -37,6 +38,7 @@ Examples:
37
38
  git-p4son sync 123 156 178 # Sync each changelist in sequence, one commit each
38
39
  git-p4son sync 123 156 head # Sync 123, 156, then the latest changelist
39
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
40
42
  git-p4son sync-split # Sync to latest, your own changelists split out
41
43
  git-p4son sync-split 12345 # Same, but stop at changelist 12345
42
44
  git-p4son sync-split -u alice -u bob # Split out alice's and bob's changelists
@@ -46,6 +48,9 @@ Examples:
46
48
  git-p4son update --shelve # Update changelist for current branch and re-shelve
47
49
  git-p4son update 12345 # Update changelist 12345
48
50
  git-p4son list-changes --base-branch main # List commit subjects since main branch
51
+ git-p4son writable # Show whether writable mode is on
52
+ git-p4son writable enable # Keep git-tracked files writable
53
+ git-p4son writable apply # Make tracked files match the writable mode
49
54
  """
50
55
  )
51
56
 
@@ -99,6 +104,11 @@ Examples:
99
104
  action='store_true',
100
105
  help='Allow syncing to changelists older than the current one.'
101
106
  )
107
+ sync_parser.add_argument(
108
+ '-n', '--dry-run',
109
+ action='store_true',
110
+ help='Print the resolved sync sequence without syncing'
111
+ )
102
112
 
103
113
  # Sync-split subcommand
104
114
  sync_split_parser = subparsers.add_parser(
@@ -352,6 +362,40 @@ Examples:
352
362
  help='Print the generated rebase todo without executing'
353
363
  )
354
364
 
365
+ # Writable subcommand
366
+ writable_parser = subparsers.add_parser(
367
+ 'writable',
368
+ help='Show or apply writable mode for git-tracked files',
369
+ description='Writable mode keeps git-tracked files writable so they '
370
+ 'can be edited without a manual p4 edit. Git-ignored files are left '
371
+ 'to Perforce. Without an action, shows whether the mode is on.'
372
+ )
373
+ writable_subparsers = writable_parser.add_subparsers(
374
+ dest='writable_action',
375
+ help='Available writable actions',
376
+ metavar='ACTION'
377
+ )
378
+ writable_subparsers.add_parser(
379
+ 'enable',
380
+ help='Turn writable mode on and make git-tracked files writable',
381
+ description='Turn writable mode on, then make every git-tracked file '
382
+ 'writable. Sync keeps the files it syncs writable from then on.'
383
+ )
384
+ writable_subparsers.add_parser(
385
+ 'disable',
386
+ help='Turn writable mode off and make git-tracked files read-only',
387
+ description='Turn writable mode off, then make git-tracked files '
388
+ 'read-only, except files opened in Perforce.'
389
+ )
390
+ writable_subparsers.add_parser(
391
+ 'apply',
392
+ help='Make git-tracked files match the writable mode',
393
+ description='With writable mode on, make every git-tracked file '
394
+ 'writable. With it off, make them read-only, except files opened in '
395
+ 'Perforce. Use it to fix files made read-only outside git-p4son, '
396
+ 'for example by a submit.'
397
+ )
398
+
355
399
  # Completion subcommand (prints shell completion script path)
356
400
  completion_parser = subparsers.add_parser(
357
401
  'completion',
@@ -370,10 +414,11 @@ Examples:
370
414
  help='Print the directory instead of the full file path'
371
415
  )
372
416
 
373
- # 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.
374
420
  seq_editor_parser = subparsers.add_parser(
375
421
  '_sequence-editor',
376
- help=argparse.SUPPRESS,
377
422
  )
378
423
  seq_editor_parser.add_argument(
379
424
  'filename',
@@ -477,6 +522,8 @@ def run_command(args: argparse.Namespace) -> int:
477
522
  return alias_command(args)
478
523
  elif args.command == 'review':
479
524
  return review_command(args)
525
+ elif args.command == 'writable':
526
+ return writable_command(args)
480
527
  elif args.command == '_sequence-editor':
481
528
  return sequence_editor_command(args)
482
529
  else:
@@ -11,6 +11,7 @@ import subprocess
11
11
  import sys
12
12
  import threading
13
13
  import time
14
+ from contextlib import AbstractContextManager, nullcontext
14
15
  from timeit import default_timer as timer
15
16
  from datetime import timedelta
16
17
  from typing import IO, Callable
@@ -127,6 +128,12 @@ class RunResult:
127
128
  self.elapsed: timedelta | None = elapsed
128
129
 
129
130
 
131
+ # Commands that take a list of files pass it after a `--` separator. With
132
+ # thousands of files synced, printing every one buries the output, so a
133
+ # longer list is logged as a count. Verbose mode still prints it in full.
134
+ _MAX_LOGGED_PATHS = 3
135
+
136
+
130
137
  def join_command_line(command: list[str]) -> str:
131
138
  command_line = ''
132
139
  for c in command:
@@ -137,6 +144,30 @@ def join_command_line(command: list[str]) -> str:
137
144
  return command_line
138
145
 
139
146
 
147
+ def _command_line_for_log(command: list[str]) -> str:
148
+ """The command line as printed, with a long path list summarized."""
149
+ if log.verbose_mode or '--' not in command:
150
+ return join_command_line(command)
151
+ separator = command.index('--')
152
+ paths = command[separator + 1:]
153
+ if len(paths) <= _MAX_LOGGED_PATHS:
154
+ return join_command_line(command)
155
+ return join_command_line(command[:separator + 1]) + f' <{len(paths)} paths>'
156
+
157
+
158
+ def batched_command_log(prefix: list[str], path_count: int,
159
+ batch_count: int) -> AbstractContextManager[None]:
160
+ """Log a command run over a path list in several batches as one line.
161
+
162
+ prefix is the command up to and including its `--` separator. A single
163
+ batch is logged as the command itself."""
164
+ if batch_count <= 1:
165
+ return nullcontext()
166
+ return log.command_batch(
167
+ join_command_line(prefix)
168
+ + f' <{path_count} paths in {batch_count} batches>')
169
+
170
+
140
171
  def run(command: list[str], cwd: str = '.', dry_run: bool = False,
141
172
  input: str | None = None,
142
173
  env: dict[str, str] | None = None,
@@ -159,7 +190,7 @@ def run(command: list[str], cwd: str = '.', dry_run: bool = False,
159
190
  RunResult object with returncode, stdout, and stderr
160
191
  """
161
192
  use_spinner = input is None and not dry_run
162
- log.command(join_command_line(command),
193
+ log.command(_command_line_for_log(command),
163
194
  truncate_for_spinner=use_spinner)
164
195
 
165
196
  if dry_run:
@@ -225,6 +256,16 @@ def enqueue_lines(stream: IO[str], output_queue: queue.Queue[str]) -> None:
225
256
  output_queue.put(line.rstrip())
226
257
 
227
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
+
228
269
  def run_with_output(command: list[str], cwd: str = '.',
229
270
  on_output: Callable[..., None] | None = None,
230
271
  env: dict[str, str] | None = None) -> RunResult:
@@ -237,12 +278,15 @@ def run_with_output(command: list[str], cwd: str = '.',
237
278
  on_output: Callback function for processing output lines
238
279
  If set the funciton will be called with each
239
280
  line and stream (stdout/stderr) as they are written.
281
+ An exception raised from it terminates the command
282
+ and propagates.
240
283
  env: Optional environment variables to add or override
241
284
 
242
285
  Returns:
243
286
  RunResult object with returncode, stdout, and stderr
244
287
  """
245
- log.command(join_command_line(command), truncate_for_spinner=True)
288
+ log.command(_command_line_for_log(command),
289
+ truncate_for_spinner=True)
246
290
  log.start_spinner()
247
291
 
248
292
  start_timestamp = timer()
@@ -320,14 +364,14 @@ def run_with_output(command: list[str], cwd: str = '.',
320
364
  except KeyboardInterrupt:
321
365
  log.stop_spinner()
322
366
  log.error("CTRL-C pressed, terminate subprocess")
323
- process.terminate()
324
- try:
325
- process.wait(timeout=5)
326
- except subprocess.TimeoutExpired:
327
- log.error(
328
- "Subprocess did not terminate in time. Forcing kill...")
329
- process.kill()
367
+ _terminate(process)
330
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
331
375
 
332
376
  log.stop_spinner()
333
377
 
@@ -14,6 +14,8 @@ from .git import get_current_branch, get_workspace_dir
14
14
  from .log import log
15
15
 
16
16
  _HIDDEN_COMMANDS = frozenset({'complete', 'completion', '_sequence-editor'})
17
+ # Commands whose first positional is a nested action (alias list, ...).
18
+ _COMMANDS_WITH_ACTIONS = frozenset({'alias', 'writable'})
17
19
 
18
20
 
19
21
  def _get_subparsers_action(parser):
@@ -125,15 +127,16 @@ def _complete_positional(command, subcommand, positional_count,
125
127
  branch_candidates = _get_branch_candidates(prefix, workspace_dir)
126
128
  return branch_candidates + _filter(aliases, prefix)
127
129
 
128
- if command == 'alias':
129
- if subcommand is None and positional_count == 0:
130
- nested = _get_subparsers_action(command_parser)
131
- if nested and hasattr(nested, '_choices_actions'):
132
- candidates = [(ca.dest, ca.help or '')
133
- for ca in nested._choices_actions]
134
- return _filter(candidates, prefix)
135
- return []
130
+ if (command in _COMMANDS_WITH_ACTIONS and subcommand is None
131
+ and positional_count == 0):
132
+ nested = _get_subparsers_action(command_parser)
133
+ if nested and hasattr(nested, '_choices_actions'):
134
+ candidates = [(ca.dest, ca.help or '')
135
+ for ca in nested._choices_actions]
136
+ return _filter(candidates, prefix)
137
+ return []
136
138
 
139
+ if command == 'alias':
137
140
  if subcommand == 'delete' and positional_count == 0:
138
141
  branch_candidates = _get_branch_candidates(prefix, workspace_dir)
139
142
  return branch_candidates + _filter(aliases, prefix)
@@ -193,7 +196,7 @@ def _complete(parser, words, workspace_dir=None):
193
196
  current_parser = command_parser
194
197
  continue
195
198
 
196
- if command == 'alias' and subcommand is None:
199
+ if command in _COMMANDS_WITH_ACTIONS and subcommand is None:
197
200
  nested = _get_subparsers_action(command_parser)
198
201
  if nested and word in nested.choices:
199
202
  subcommand = word
@@ -12,11 +12,26 @@ from . import CONFIG_DIR
12
12
 
13
13
  _BARE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
14
14
 
15
- # Placeholder allowed in a stored depot root, substituted with the live
16
- # Perforce client (workspace) name each time the root is used. Storing e.g.
17
- # root = "//$(workspace)/Engine" keeps the config working after the workspace
18
- # is renamed, at the cost of one client-name lookup per command.
19
- WORKSPACE_PLACEHOLDER = '$(workspace)'
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)
20
35
 
21
36
 
22
37
  def config_path(workspace_dir: str) -> str:
@@ -55,6 +70,7 @@ def save_config(workspace_dir: str, config: dict) -> None:
55
70
  merged = load_config(workspace_dir)
56
71
  for section, values in config.items():
57
72
  merged.setdefault(section, {}).update(values)
73
+ ensure_config_dir(workspace_dir)
58
74
  write_toml(config_path(workspace_dir), merged)
59
75
 
60
76
 
@@ -94,14 +110,3 @@ def _write_table(f, name: str, table: dict) -> None:
94
110
  f.write('\n')
95
111
  for key, subtable in subtables.items():
96
112
  _write_table(f, f'{name}.{_format_key(key)}', subtable)
97
-
98
-
99
- def get_depot_root(workspace_dir: str) -> str | None:
100
- """Get the depot root from config, or None if not configured."""
101
- config = load_config(workspace_dir)
102
- return config.get('depot', {}).get('root')
103
-
104
-
105
- def expand_depot_root(depot_root: str, workspace_name: str) -> str:
106
- """Substitute the live workspace name for the $(workspace) placeholder."""
107
- return depot_root.replace(WORKSPACE_PLACEHOLDER, workspace_name)