git-p4son 0.3.4__tar.gz → 0.4.0__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 (61) hide show
  1. {git_p4son-0.3.4 → git_p4son-0.4.0}/PKG-INFO +72 -4
  2. {git_p4son-0.3.4 → git_p4son-0.4.0}/README.md +71 -3
  3. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/cli.py +40 -0
  5. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/common.py +34 -2
  6. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/complete.py +12 -9
  7. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/config.py +0 -17
  8. git_p4son-0.4.0/git_p4son/depot.py +62 -0
  9. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/git.py +36 -19
  10. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/init.py +50 -6
  11. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/lib.py +77 -1
  12. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/log.py +32 -4
  13. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/new.py +20 -1
  14. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/perforce.py +57 -8
  15. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/sync.py +33 -50
  16. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/sync_split.py +5 -3
  17. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/update.py +20 -1
  18. git_p4son-0.4.0/git_p4son/writable.py +155 -0
  19. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son.egg-info/PKG-INFO +72 -4
  20. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son.egg-info/SOURCES.txt +7 -1
  21. {git_p4son-0.3.4 → git_p4son-0.4.0}/pyproject.toml +1 -1
  22. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_cli.py +16 -0
  23. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_common.py +48 -0
  24. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_complete.py +7 -1
  25. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_config.py +0 -33
  26. git_p4son-0.4.0/tests/test_depot.py +42 -0
  27. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_git.py +54 -1
  28. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_init.py +94 -0
  29. git_p4son-0.4.0/tests/test_lib_revert.py +251 -0
  30. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_lib_review.py +10 -20
  31. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_log.py +60 -0
  32. git_p4son-0.4.0/tests/test_new.py +121 -0
  33. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_sync.py +163 -43
  34. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_sync_split.py +24 -6
  35. git_p4son-0.4.0/tests/test_update.py +85 -0
  36. git_p4son-0.4.0/tests/test_writable.py +239 -0
  37. git_p4son-0.3.4/tests/test_new.py +0 -56
  38. {git_p4son-0.3.4 → git_p4son-0.4.0}/LICENSE +0 -0
  39. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/__main__.py +0 -0
  40. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/alias.py +0 -0
  41. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/changelist_store.py +0 -0
  42. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/completions/_git-p4son +0 -0
  43. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/completions/git-p4son.bash +0 -0
  44. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/completions/git-p4son.ps1 +0 -0
  45. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/hooks.py +0 -0
  46. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/list_changes.py +0 -0
  47. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/review.py +0 -0
  48. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son/state.py +0 -0
  49. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son.egg-info/dependency_links.txt +0 -0
  50. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son.egg-info/entry_points.txt +0 -0
  51. {git_p4son-0.3.4 → git_p4son-0.4.0}/git_p4son.egg-info/top_level.txt +0 -0
  52. {git_p4son-0.3.4 → git_p4son-0.4.0}/setup.cfg +0 -0
  53. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_alias.py +0 -0
  54. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_changelist_store.py +0 -0
  55. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_hooks.py +0 -0
  56. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_lib_changelist.py +0 -0
  57. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_lib_edit.py +0 -0
  58. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_list_changes.py +0 -0
  59. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_perforce.py +0 -0
  60. {git_p4son-0.3.4 → git_p4son-0.4.0}/tests/test_review.py +0 -0
  61. {git_p4son-0.3.4 → git_p4son-0.4.0}/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.4
3
+ Version: 0.4.0
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
 
@@ -284,6 +287,9 @@ The `.gitignore` is set up using this priority:
284
287
  - If `.p4ignore` exists, it is copied to `.gitignore` as a starting point
285
288
  - Otherwise, an empty `.gitignore` is created
286
289
 
290
+ `init` also asks whether to turn on [writable mode](#writable-command), defaulting to the current setting. It only
291
+ saves the answer; run `git p4son writable apply` once your files are committed to update their permissions.
292
+
287
293
  ### Sync Command
288
294
 
289
295
  Sync local git repository with a Perforce workspace:
@@ -379,6 +385,10 @@ than the last synced changelist (use `git p4son sync --force` for that).
379
385
  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
386
  Optionally creates a Swarm review.
381
387
 
388
+ Opening files requires a clean git workspace, untracked files included; `new` refuses to run otherwise. The check
389
+ is skipped with `--no-edit`. After opening files, unchanged ones are reverted (see
390
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)).
391
+
382
392
  ```sh
383
393
  git p4son new -m <message> [alias] [--base-branch BASE_BRANCH] [--force] [--dry-run] [--no-edit] [--no-alias]
384
394
  [--shelve] [--review]
@@ -414,7 +424,10 @@ Update an existing Perforce changelist description. Commits since the base branc
414
424
  entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
415
425
  range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
416
426
  it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
417
- edit.
427
+ edit, and reverts files that are no longer part of the git change (see
428
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)). Opening files requires a
429
+ clean git workspace, untracked files included; `update` refuses to run otherwise. The check is skipped with
430
+ `--no-edit`.
418
431
 
419
432
  ```sh
420
433
  git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
@@ -440,6 +453,27 @@ git p4son update 12345
440
453
  git p4son update myalias -b main
441
454
  ```
442
455
 
456
+ ### Keeping the changelist in step with git
457
+
458
+ `new` and `update` open every file changed since the base branch in the changelist. A file can later stop being
459
+ part of the change, for example when a later commit undoes an edit, or a file is added in one commit and deleted
460
+ in the next. Such files would otherwise stay opened as no-ops, so after opening files both commands revert them:
461
+
462
+ - Files opened for edit that are unchanged compared to the depot are reverted with `p4 revert -a`. Changed files
463
+ stay opened.
464
+ - Files opened for add that are missing from disk are reverted.
465
+ - Files opened for delete that git still has (deleted in one commit, restored in a later one) are reverted and
466
+ restored from git.
467
+
468
+ Only files git-p4son has authority over are touched: files tracked by git, and files opened for add that are
469
+ missing from disk. Anything else in the changelist, such as binaries that only exist in Perforce and were opened
470
+ by hand, is left alone even when unchanged. Files opened with other actions (`move/add`, `integrate`, ...) are
471
+ also left alone. This step is skipped with `--no-edit`.
472
+
473
+ Shelving (`--shelve`, `--review`) replaces the whole shelf with the changelist's opened files using
474
+ `p4 shelve -r -a leaveunchanged`, so the shelf never holds stale files, and unchanged files that are still opened
475
+ are left out of it.
476
+
443
477
  ### Review Command
444
478
 
445
479
  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 +617,40 @@ In interactive mode, each alias is displayed in turn with a prompt:
583
617
  git p4son alias clean
584
618
  ```
585
619
 
620
+ ### Writable Command
621
+
622
+ Writable mode keeps git-tracked files writable, so you can edit them without running `p4 edit` first. git-p4son
623
+ opens changed files in Perforce itself when you run `new`, `update` or `review`. Git-ignored files, such as content
624
+ and other Perforce-only files, stay read-only and are checked out through Perforce as usual.
625
+
626
+ ```sh
627
+ git p4son writable # Show whether writable mode is on
628
+ git p4son writable enable # Turn writable mode on, then apply it
629
+ git p4son writable disable # Turn writable mode off, then apply it
630
+ git p4son writable apply # Make git-tracked files match the current mode
631
+ ```
632
+
633
+ `apply` makes every git-tracked file writable when the mode is on. When it's off, it makes them read-only, except
634
+ files opened in Perforce in any changelist. On a workspace with the `allwrite` option, making files read-only is
635
+ skipped, since the client spec wants every file writable. Git-ignored files are never changed.
636
+
637
+ The setting is stored in `.git-p4son/config.toml`:
638
+
639
+ ```toml
640
+ [core]
641
+ writable = true
642
+ ```
643
+
644
+ With the mode on, `sync` makes the files it synced writable again if git tracks them, since Perforce writes synced
645
+ files read-only. Files made read-only outside git-p4son, for example by submitting from P4V, `p4 revert`, or "Get
646
+ Latest" in P4V, stay read-only until a later sync touches them. Run `git p4son writable apply` to fix them right
647
+ away.
648
+
649
+ This replaces the `post-sync` hook some users run to make every tracked file writable after a sync.
650
+
651
+ Turning the mode off also makes tracked files whose Perforce type carries the `+w` (always writable) modifier
652
+ read-only. The next sync of such a file makes it writable again.
653
+
586
654
  ### Completion Command
587
655
 
588
656
  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
 
@@ -261,6 +264,9 @@ The `.gitignore` is set up using this priority:
261
264
  - If `.p4ignore` exists, it is copied to `.gitignore` as a starting point
262
265
  - Otherwise, an empty `.gitignore` is created
263
266
 
267
+ `init` also asks whether to turn on [writable mode](#writable-command), defaulting to the current setting. It only
268
+ saves the answer; run `git p4son writable apply` once your files are committed to update their permissions.
269
+
264
270
  ### Sync Command
265
271
 
266
272
  Sync local git repository with a Perforce workspace:
@@ -356,6 +362,10 @@ than the last synced changelist (use `git p4son sync --force` for that).
356
362
  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
363
  Optionally creates a Swarm review.
358
364
 
365
+ Opening files requires a clean git workspace, untracked files included; `new` refuses to run otherwise. The check
366
+ is skipped with `--no-edit`. After opening files, unchanged ones are reverted (see
367
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)).
368
+
359
369
  ```sh
360
370
  git p4son new -m <message> [alias] [--base-branch BASE_BRANCH] [--force] [--dry-run] [--no-edit] [--no-alias]
361
371
  [--shelve] [--review]
@@ -391,7 +401,10 @@ Update an existing Perforce changelist description. Commits since the base branc
391
401
  entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
392
402
  range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
393
403
  it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
394
- edit.
404
+ edit, and reverts files that are no longer part of the git change (see
405
+ [Keeping the changelist in step with git](#keeping-the-changelist-in-step-with-git)). Opening files requires a
406
+ clean git workspace, untracked files included; `update` refuses to run otherwise. The check is skipped with
407
+ `--no-edit`.
395
408
 
396
409
  ```sh
397
410
  git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
@@ -417,6 +430,27 @@ git p4son update 12345
417
430
  git p4son update myalias -b main
418
431
  ```
419
432
 
433
+ ### Keeping the changelist in step with git
434
+
435
+ `new` and `update` open every file changed since the base branch in the changelist. A file can later stop being
436
+ part of the change, for example when a later commit undoes an edit, or a file is added in one commit and deleted
437
+ in the next. Such files would otherwise stay opened as no-ops, so after opening files both commands revert them:
438
+
439
+ - Files opened for edit that are unchanged compared to the depot are reverted with `p4 revert -a`. Changed files
440
+ stay opened.
441
+ - Files opened for add that are missing from disk are reverted.
442
+ - Files opened for delete that git still has (deleted in one commit, restored in a later one) are reverted and
443
+ restored from git.
444
+
445
+ Only files git-p4son has authority over are touched: files tracked by git, and files opened for add that are
446
+ missing from disk. Anything else in the changelist, such as binaries that only exist in Perforce and were opened
447
+ by hand, is left alone even when unchanged. Files opened with other actions (`move/add`, `integrate`, ...) are
448
+ also left alone. This step is skipped with `--no-edit`.
449
+
450
+ Shelving (`--shelve`, `--review`) replaces the whole shelf with the changelist's opened files using
451
+ `p4 shelve -r -a leaveunchanged`, so the shelf never holds stale files, and unchanged files that are still opened
452
+ are left out of it.
453
+
420
454
  ### Review Command
421
455
 
422
456
  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 +594,40 @@ In interactive mode, each alias is displayed in turn with a prompt:
560
594
  git p4son alias clean
561
595
  ```
562
596
 
597
+ ### Writable Command
598
+
599
+ Writable mode keeps git-tracked files writable, so you can edit them without running `p4 edit` first. git-p4son
600
+ opens changed files in Perforce itself when you run `new`, `update` or `review`. Git-ignored files, such as content
601
+ and other Perforce-only files, stay read-only and are checked out through Perforce as usual.
602
+
603
+ ```sh
604
+ git p4son writable # Show whether writable mode is on
605
+ git p4son writable enable # Turn writable mode on, then apply it
606
+ git p4son writable disable # Turn writable mode off, then apply it
607
+ git p4son writable apply # Make git-tracked files match the current mode
608
+ ```
609
+
610
+ `apply` makes every git-tracked file writable when the mode is on. When it's off, it makes them read-only, except
611
+ files opened in Perforce in any changelist. On a workspace with the `allwrite` option, making files read-only is
612
+ skipped, since the client spec wants every file writable. Git-ignored files are never changed.
613
+
614
+ The setting is stored in `.git-p4son/config.toml`:
615
+
616
+ ```toml
617
+ [core]
618
+ writable = true
619
+ ```
620
+
621
+ With the mode on, `sync` makes the files it synced writable again if git tracks them, since Perforce writes synced
622
+ files read-only. Files made read-only outside git-p4son, for example by submitting from P4V, `p4 revert`, or "Get
623
+ Latest" in P4V, stay read-only until a later sync touches them. Run `git p4son writable apply` to fix them right
624
+ away.
625
+
626
+ This replaces the `post-sync` hook some users run to make every tracked file writable after a sync.
627
+
628
+ Turning the mode off also makes tracked files whose Perforce type carries the `+w` (always writable) modifier
629
+ read-only. The next sync of such a file makes it writable again.
630
+
563
631
  ### Completion Command
564
632
 
565
633
  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.0"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -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:
@@ -46,6 +47,9 @@ Examples:
46
47
  git-p4son update --shelve # Update changelist for current branch and re-shelve
47
48
  git-p4son update 12345 # Update changelist 12345
48
49
  git-p4son list-changes --base-branch main # List commit subjects since main branch
50
+ git-p4son writable # Show whether writable mode is on
51
+ git-p4son writable enable # Keep git-tracked files writable
52
+ git-p4son writable apply # Make tracked files match the writable mode
49
53
  """
50
54
  )
51
55
 
@@ -352,6 +356,40 @@ Examples:
352
356
  help='Print the generated rebase todo without executing'
353
357
  )
354
358
 
359
+ # Writable subcommand
360
+ writable_parser = subparsers.add_parser(
361
+ 'writable',
362
+ help='Show or apply writable mode for git-tracked files',
363
+ description='Writable mode keeps git-tracked files writable so they '
364
+ 'can be edited without a manual p4 edit. Git-ignored files are left '
365
+ 'to Perforce. Without an action, shows whether the mode is on.'
366
+ )
367
+ writable_subparsers = writable_parser.add_subparsers(
368
+ dest='writable_action',
369
+ help='Available writable actions',
370
+ metavar='ACTION'
371
+ )
372
+ writable_subparsers.add_parser(
373
+ 'enable',
374
+ help='Turn writable mode on and make git-tracked files writable',
375
+ description='Turn writable mode on, then make every git-tracked file '
376
+ 'writable. Sync keeps the files it syncs writable from then on.'
377
+ )
378
+ writable_subparsers.add_parser(
379
+ 'disable',
380
+ help='Turn writable mode off and make git-tracked files read-only',
381
+ description='Turn writable mode off, then make git-tracked files '
382
+ 'read-only, except files opened in Perforce.'
383
+ )
384
+ writable_subparsers.add_parser(
385
+ 'apply',
386
+ help='Make git-tracked files match the writable mode',
387
+ description='With writable mode on, make every git-tracked file '
388
+ 'writable. With it off, make them read-only, except files opened in '
389
+ 'Perforce. Use it to fix files made read-only outside git-p4son, '
390
+ 'for example by a submit.'
391
+ )
392
+
355
393
  # Completion subcommand (prints shell completion script path)
356
394
  completion_parser = subparsers.add_parser(
357
395
  'completion',
@@ -477,6 +515,8 @@ def run_command(args: argparse.Namespace) -> int:
477
515
  return alias_command(args)
478
516
  elif args.command == 'review':
479
517
  return review_command(args)
518
+ elif args.command == 'writable':
519
+ return writable_command(args)
480
520
  elif args.command == '_sequence-editor':
481
521
  return sequence_editor_command(args)
482
522
  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:
@@ -242,7 +273,8 @@ def run_with_output(command: list[str], cwd: str = '.',
242
273
  Returns:
243
274
  RunResult object with returncode, stdout, and stderr
244
275
  """
245
- log.command(join_command_line(command), truncate_for_spinner=True)
276
+ log.command(_command_line_for_log(command),
277
+ truncate_for_spinner=True)
246
278
  log.start_spinner()
247
279
 
248
280
  start_timestamp = timer()
@@ -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,12 +12,6 @@ from . import CONFIG_DIR
12
12
 
13
13
  _BARE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
14
14
 
15
- # Placeholder allowed in a stored depot root, substituted with the live
16
- # Perforce client (workspace) name each time the root is used. Storing e.g.
17
- # root = "//$(workspace)/Engine" keeps the config working after the workspace
18
- # is renamed, at the cost of one client-name lookup per command.
19
- WORKSPACE_PLACEHOLDER = '$(workspace)'
20
-
21
15
 
22
16
  def config_path(workspace_dir: str) -> str:
23
17
  """Return the path to the config file."""
@@ -94,14 +88,3 @@ def _write_table(f, name: str, table: dict) -> None:
94
88
  f.write('\n')
95
89
  for key, subtable in subtables.items():
96
90
  _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)
@@ -0,0 +1,62 @@
1
+ """
2
+ Depot root resolution for git-p4son.
3
+
4
+ The depot root is the Perforce path git-p4son syncs, stored in
5
+ .git-p4son/config.toml. This module reads it, expands the $(workspace)
6
+ placeholder, and resolves it into the path Perforce commands run against.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from .config import load_config
12
+ from .log import log
13
+ from .perforce import P4ClientSpec, get_client_spec
14
+
15
+ # Placeholder allowed in a stored depot root, substituted with the live
16
+ # Perforce client (workspace) name each time the root is used. Storing e.g.
17
+ # root = "//$(workspace)/Engine" keeps the config working after the workspace
18
+ # is renamed, at the cost of one client-name lookup per command.
19
+ WORKSPACE_PLACEHOLDER = '$(workspace)'
20
+
21
+
22
+ def get_depot_root(workspace_dir: str) -> str | None:
23
+ """Get the depot root from config, or None if not configured."""
24
+ config = load_config(workspace_dir)
25
+ return config.get('depot', {}).get('root')
26
+
27
+
28
+ def expand_depot_root(depot_root: str, workspace_name: str) -> str:
29
+ """Substitute the live workspace name for the $(workspace) placeholder."""
30
+ return depot_root.replace(WORKSPACE_PLACEHOLDER, workspace_name)
31
+
32
+
33
+ @dataclass
34
+ class ResolvedDepot:
35
+ """The configured depot root with any placeholder expanded, plus the
36
+ client spec it was resolved against."""
37
+ depot_root: str
38
+ client_spec: P4ClientSpec | None
39
+
40
+
41
+ def resolve_depot_root(workspace_dir: str) -> ResolvedDepot | None:
42
+ """Resolve the configured depot root, or None (with an error logged).
43
+
44
+ The client spec is queried once here: its name resolves a $(workspace)
45
+ placeholder in the depot root, and its line-ending/clobber options feed
46
+ the writable-file handling in sync.
47
+ """
48
+ log.heading('Finding depot root')
49
+ depot_root = get_depot_root(workspace_dir)
50
+ if not depot_root:
51
+ log.error('No depot root configured. Run "git p4son init" first.')
52
+ return None
53
+
54
+ client_spec = get_client_spec(workspace_dir)
55
+ if WORKSPACE_PLACEHOLDER in depot_root and not client_spec:
56
+ log.error('Cannot resolve $(workspace) in depot root: not inside a '
57
+ 'Perforce workspace')
58
+ return None
59
+ if client_spec:
60
+ depot_root = expand_depot_root(depot_root, client_spec.name)
61
+ log.success(depot_root)
62
+ return ResolvedDepot(depot_root=depot_root, client_spec=client_spec)
@@ -10,6 +10,7 @@ import re
10
10
  from .common import (
11
11
  CommandError,
12
12
  RunError,
13
+ batched_command_log,
13
14
  normalize_workspace_path,
14
15
  run,
15
16
  run_with_output,
@@ -252,18 +253,28 @@ def get_tracked_files(filepaths: list[str], workspace_dir: str) -> set[str]:
252
253
  tracked: set[str] = set()
253
254
  chunks = _chunk_paths_by_length(
254
255
  list(by_git_path), _PATHSPEC_LENGTH_BUDGET)
255
- for chunk in chunks:
256
- # -z output is NUL-separated and verbatim; without it paths with
257
- # non-ASCII characters are C-quoted and would never match.
258
- result = run(['git', 'ls-files', '-z', '--'] + chunk,
259
- cwd=workspace_dir)
260
- for line in result.stdout:
261
- for git_path in line.split('\0'):
262
- if git_path in by_git_path:
263
- tracked.add(by_git_path[git_path])
256
+ # -z output is NUL-separated and verbatim; without it paths with
257
+ # non-ASCII characters are C-quoted and would never match.
258
+ command = ['git', 'ls-files', '-z', '--']
259
+ with batched_command_log(command, len(by_git_path), len(chunks)):
260
+ for chunk in chunks:
261
+ result = run(command + chunk, cwd=workspace_dir)
262
+ for line in result.stdout:
263
+ for git_path in line.split('\0'):
264
+ if git_path in by_git_path:
265
+ tracked.add(by_git_path[git_path])
264
266
  return tracked
265
267
 
266
268
 
269
+ def list_tracked_files(workspace_dir: str) -> list[str]:
270
+ """Return every file tracked by git, as repo-relative slash paths."""
271
+ # -z output is NUL-separated and verbatim, so non-ASCII paths come back
272
+ # as-is rather than C-quoted. run() splits its output on newlines, so
273
+ # they are rejoined first in case a path contains one.
274
+ result = run(['git', 'ls-files', '-z'], cwd=workspace_dir)
275
+ return [path for path in '\n'.join(result.stdout).split('\0') if path]
276
+
277
+
267
278
  # --- file retrieval ---
268
279
 
269
280
  def get_file_at_commit(filepath: str, commit: str,
@@ -361,14 +372,23 @@ def find_base_commits(filepaths: list[str], before_commit: str,
361
372
  by_git_path = {fp.replace('\\', '/'): fp for fp in filepaths}
362
373
  chunks = _chunk_paths_by_length(
363
374
  list(by_git_path), _PATHSPEC_LENGTH_BUDGET)
364
- for chunk in chunks:
365
- chunk_result = _find_base_commits_chunk(
366
- chunk, before_commit, workspace_dir)
367
- for git_path, sha in chunk_result.items():
368
- result[by_git_path[git_path]] = sha
375
+ with batched_command_log(_find_base_commits_command(before_commit),
376
+ len(by_git_path), len(chunks)):
377
+ for chunk in chunks:
378
+ chunk_result = _find_base_commits_chunk(
379
+ chunk, before_commit, workspace_dir)
380
+ for git_path, sha in chunk_result.items():
381
+ result[by_git_path[git_path]] = sha
369
382
  return result
370
383
 
371
384
 
385
+ def _find_base_commits_command(before_commit: str) -> list[str]:
386
+ """The history walk behind find_base_commits, up to its `--`."""
387
+ return ['git', '-c', 'core.quotePath=false', 'log', '--no-renames',
388
+ '--name-status', '--pretty=format:%x01%H%x01%s',
389
+ before_commit, '--']
390
+
391
+
372
392
  def _find_base_commits_chunk(git_paths: list[str], before_commit: str,
373
393
  workspace_dir: str) -> dict[str, str | None]:
374
394
  """One newest-first history walk resolving baselines for git_paths.
@@ -378,11 +398,8 @@ def _find_base_commits_chunk(git_paths: list[str], before_commit: str,
378
398
  no sync commit ever touched. core.quotePath is disabled so non-ASCII
379
399
  paths in --name-status output match the input verbatim."""
380
400
  result: dict[str, str | None] = dict.fromkeys(git_paths)
381
- res = run(
382
- ['git', '-c', 'core.quotePath=false', 'log', '--no-renames',
383
- '--name-status', '--pretty=format:%x01%H%x01%s',
384
- before_commit, '--'] + git_paths,
385
- cwd=workspace_dir, fail_on_returncode=False)
401
+ res = run(_find_base_commits_command(before_commit) + git_paths,
402
+ cwd=workspace_dir, fail_on_returncode=False)
386
403
  if res.returncode != 0:
387
404
  return result
388
405