git-p4son 0.2.10__tar.gz → 0.3.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 (57) hide show
  1. {git_p4son-0.2.10 → git_p4son-0.3.0}/PKG-INFO +55 -28
  2. {git_p4son-0.2.10 → git_p4son-0.3.0}/README.md +54 -27
  3. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/__init__.py +1 -1
  4. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/alias.py +38 -29
  5. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/changelist_store.py +36 -6
  6. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/cli.py +13 -12
  7. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/common.py +95 -41
  8. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/complete.py +5 -0
  9. git_p4son-0.3.0/git_p4son/config.py +96 -0
  10. git_p4son-0.3.0/git_p4son/git.py +438 -0
  11. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/hooks.py +14 -5
  12. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/init.py +16 -13
  13. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/lib.py +67 -17
  14. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/log.py +48 -4
  15. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/new.py +4 -2
  16. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/perforce.py +125 -17
  17. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/review.py +6 -0
  18. git_p4son-0.3.0/git_p4son/state.py +56 -0
  19. git_p4son-0.3.0/git_p4son/sync.py +699 -0
  20. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son.egg-info/PKG-INFO +55 -28
  21. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son.egg-info/SOURCES.txt +5 -0
  22. {git_p4son-0.2.10 → git_p4son-0.3.0}/pyproject.toml +1 -1
  23. git_p4son-0.3.0/tests/test_alias.py +107 -0
  24. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_changelist_store.py +59 -1
  25. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_cli.py +8 -1
  26. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_common.py +101 -6
  27. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_complete.py +40 -0
  28. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_config.py +47 -0
  29. git_p4son-0.3.0/tests/test_git.py +407 -0
  30. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_hooks.py +46 -3
  31. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_init.py +38 -12
  32. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_lib_changelist.py +82 -21
  33. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_lib_edit.py +106 -4
  34. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_lib_review.py +27 -11
  35. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_list_changes.py +2 -1
  36. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_log.py +32 -0
  37. git_p4son-0.3.0/tests/test_new.py +56 -0
  38. git_p4son-0.3.0/tests/test_perforce.py +255 -0
  39. {git_p4son-0.2.10 → git_p4son-0.3.0}/tests/test_review.py +16 -1
  40. git_p4son-0.3.0/tests/test_state.py +64 -0
  41. git_p4son-0.3.0/tests/test_sync.py +1205 -0
  42. git_p4son-0.2.10/git_p4son/config.py +0 -42
  43. git_p4son-0.2.10/git_p4son/git.py +0 -219
  44. git_p4son-0.2.10/git_p4son/sync.py +0 -255
  45. git_p4son-0.2.10/tests/test_perforce.py +0 -130
  46. git_p4son-0.2.10/tests/test_sync.py +0 -486
  47. {git_p4son-0.2.10 → git_p4son-0.3.0}/LICENSE +0 -0
  48. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/__main__.py +0 -0
  49. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/completions/_git-p4son +0 -0
  50. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/completions/git-p4son.bash +0 -0
  51. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/completions/git-p4son.ps1 +0 -0
  52. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/list_changes.py +0 -0
  53. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son/update.py +0 -0
  54. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son.egg-info/dependency_links.txt +0 -0
  55. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son.egg-info/entry_points.txt +0 -0
  56. {git_p4son-0.2.10 → git_p4son-0.3.0}/git_p4son.egg-info/top_level.txt +0 -0
  57. {git_p4son-0.2.10 → git_p4son-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-p4son
3
- Version: 0.2.10
3
+ Version: 0.3.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
@@ -156,41 +156,37 @@ cloning.
156
156
 
157
157
  These steps set up git-p4son in an existing Perforce workspace. You only need to do this once.
158
158
 
159
- 1. **Enable clobber on your Perforce workspace.** Edit the workspace in
160
- P4V to set the clobber flag, or run `p4 client` and change `noclobber` to `clobber` in the Options line.
161
- See [Why clobber?](#why-clobber) for rationale.
162
-
163
- 2. **Set a git editor** if you don't have one already. The `review` command opens an interactive rebase in your
159
+ 1. **Set a git editor** if you don't have one already. The `review` command opens an interactive rebase in your
164
160
  editor. If you haven't configured one, set it with:
165
161
  ```sh
166
162
  git config --global core.editor <editor> # e.g. vim, nano, code --wait
167
163
  ```
168
164
 
169
- 3. **Sync your workspace to a known changelist.** Pick a changelist to use as the starting point for your git
165
+ 2. **Sync your workspace to a known changelist.** Pick a changelist to use as the starting point for your git
170
166
  history:
171
167
  ```sh
172
168
  p4 sync //...@12345
173
169
  ```
174
170
 
175
- 4. **Run `git p4son init`.** This can be anywhere inside your Perforce workspace — it doesn't have to be at the
171
+ 3. **Run `git p4son init`.** This can be anywhere inside your Perforce workspace - it doesn't have to be at the
176
172
  root:
177
173
  ```sh
178
174
  cd /path/to/your/workspace # or a subdirectory of it
179
175
  git p4son init
180
176
  ```
181
- The command verifies that you are inside a P4 workspace with clobber enabled, prompts you to select a depot
177
+ The command verifies that you are inside a P4 workspace, prompts you to select a depot
182
178
  root (entire workspace or current directory subtree), runs `git init`, sets up `.gitignore` (copying from
183
179
  `.p4ignore` if available), and creates an initial commit.
184
180
 
185
- 5. **Review `.gitignore`.** Edit the file to ensure build artifacts and other unwanted files are excluded.
181
+ 4. **Review `.gitignore`.** Edit the file to ensure build artifacts and other unwanted files are excluded.
186
182
 
187
- 6. **Add and commit all files manually the first time**
183
+ 5. **Add and commit all files manually the first time**
188
184
  ```sh
189
185
  git add .
190
186
  git commit -m "Initial submit all files"
191
187
  ```
192
188
 
193
- 7. **Run `git p4son sync`** to get your first official sync commit
189
+ 6. **Run `git p4son sync`** to get your first official sync commit
194
190
  ```sh
195
191
  git p4son sync
196
192
  ```
@@ -199,19 +195,41 @@ These steps set up git-p4son in an existing Perforce workspace. You only need to
199
195
  From here, branch off `main` for local development. See the [Usage Example](#usage-example) for a typical
200
196
  workflow.
201
197
 
202
- ### Why clobber?
198
+ ### How sync handles writable files
199
+
200
+ When you switch branches with `git checkout`, git removes the read-only flag on every file it writes. This means
201
+ that when you switch back to `main` for a sync, Perforce may report "Can't clobber writable file" errors. git-p4son
202
+ handles these automatically by classifying each writable file:
203
+
204
+ **writable-unchanged** - The file on disk is identical to the last sync (only the read-only flag was removed
205
+ by git). This is the common case after switching branches. git-p4son detects this by comparing git blob IDs against
206
+ the last sync commit that touched the file, restores the read-only flag, and lets the sync overwrite the file.
207
+
208
+ **writable-changed** - The file on disk differs from the last sync. This happens when you manually edit a file
209
+ on `main` and commit it to git without submitting to Perforce. git-p4son lets the sync overwrite the file and
210
+ includes it in the sync commit (so the commit reflects pure Perforce state), then three-way merges your local
211
+ changes on top of the new Perforce content - similar to a `git rebase`. If the merge is clean, the result appears
212
+ as an unstaged change. If there are conflicts, standard conflict markers are inserted for you to resolve.
203
213
 
204
- When you switch branches with `git checkout`, git overwrites the files that differ between the source and target branch.
205
- In doing so, it always removes the read-only flag - this is just how git works.
214
+ **added both locally and in Perforce** - A file you committed to git is also being added by Perforce (p4 has never
215
+ synced it to your workspace). There is no common baseline, so git-p4son merges the two versions against an empty
216
+ base: identical content merges cleanly, otherwise the file gets a single conflict block containing both full
217
+ versions for you to resolve.
206
218
 
207
- Now imagine one of those files has also been modified in Perforce. The next time `p4 sync` runs, Perforce will complain
208
- that a file was changed outside its control. At that point you have two options: either force-sync the individual file
209
- with `p4 sync -f //path/to/file`, or enable the `clobber` flag on your workspace so that Perforce silently overwrites
210
- writable files.
219
+ **binary files** - Binary files that have local changes cannot be three-way merged. git-p4son restores your local
220
+ version to disk after the sync commit so it shows up in `git status` for you to handle manually.
211
221
 
212
- This is not specific to git-p4son - any workflow that combines git and Perforce in the same workspace will run into it.
213
- And in practice it is harmless: your local changes live on feature branches in git, so when you rebase onto `main` after
214
- a sync, your changes are reapplied on top of the latest Perforce state.
222
+ **added locally, deleted upstream** - A file with local changes in git was deleted in Perforce. git-p4son lets the
223
+ delete stand and warns you - your version remains available in git history.
224
+
225
+ **deleted locally, added upstream** - A file you deleted from git was modified in Perforce. The Perforce version is
226
+ kept in the sync commit. You can delete it again if you want.
227
+
228
+ **ignored** - Files ignored by `.gitignore` are skipped with a warning. These are outside git's domain and should
229
+ be managed manually or via Perforce directly.
230
+
231
+ After syncing, git-p4son prints a summary of how each writable file was handled, and instructs you to review and
232
+ commit any files that need attention.
215
233
 
216
234
  ## Usage
217
235
 
@@ -241,7 +259,7 @@ Initialize a git repository inside a Perforce workspace:
241
259
  git p4son init
242
260
  ```
243
261
 
244
- This command checks preconditions (Perforce workspace, clobber flag), configures the depot root, runs `git init`,
262
+ This command checks preconditions (Perforce workspace), configures the depot root, runs `git init`,
245
263
  sets up `.gitignore`, and creates an initial commit.
246
264
 
247
265
  The depot root determines which part of the Perforce workspace git-p4son syncs. You can choose to sync the entire
@@ -266,7 +284,7 @@ git p4son sync [changelist] [--force]
266
284
  sync to the latest changelist affecting the workspace.
267
285
 
268
286
  **Options:**
269
- - `-f, --force`: Force sync encountered writable files and allow syncing to older changelists.
287
+ - `-f, --force`: Allow syncing to changelists older than the current one.
270
288
 
271
289
  **Examples:**
272
290
  ```sh
@@ -317,7 +335,11 @@ git p4son new -m "New feature" --review -b main
317
335
 
318
336
  ### Update Command
319
337
 
320
- Update an existing Perforce changelist description by replacing the enumerated commit list with the current commits since the base branch. By default also opens changed files for edit.
338
+ Update an existing Perforce changelist description. Commits since the base branch replace their existing
339
+ entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
340
+ range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
341
+ it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
342
+ edit.
321
343
 
322
344
  ```sh
323
345
  git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
@@ -464,16 +486,21 @@ git p4son alias delete myfeature
464
486
 
465
487
  #### alias clean
466
488
 
467
- Interactively review and delete changelist aliases:
489
+ Clean up changelist aliases:
468
490
 
469
491
  ```sh
470
492
  git p4son alias clean
471
493
  ```
472
494
 
473
- This command iterates through each alias, displays it, and prompts for action:
495
+ The command lists all aliases and asks how to proceed:
496
+ - `a` (all): Delete all aliases
497
+ - `i` (interactive): Review each alias one by one
498
+ - `q` (quit): Exit without deleting anything
499
+
500
+ In interactive mode, each alias is displayed in turn with a prompt:
474
501
  - `y` (yes): Delete this alias
475
502
  - `n` (no): Keep this alias
476
- - `a` (all): Delete this and all remaining aliases
503
+ - `a` (all): Delete this and all remaining aliases (handy for keeping the first few and sweeping the rest)
477
504
  - `q` (quit): Stop and keep remaining aliases
478
505
 
479
506
  **Examples:**
@@ -133,41 +133,37 @@ cloning.
133
133
 
134
134
  These steps set up git-p4son in an existing Perforce workspace. You only need to do this once.
135
135
 
136
- 1. **Enable clobber on your Perforce workspace.** Edit the workspace in
137
- P4V to set the clobber flag, or run `p4 client` and change `noclobber` to `clobber` in the Options line.
138
- See [Why clobber?](#why-clobber) for rationale.
139
-
140
- 2. **Set a git editor** if you don't have one already. The `review` command opens an interactive rebase in your
136
+ 1. **Set a git editor** if you don't have one already. The `review` command opens an interactive rebase in your
141
137
  editor. If you haven't configured one, set it with:
142
138
  ```sh
143
139
  git config --global core.editor <editor> # e.g. vim, nano, code --wait
144
140
  ```
145
141
 
146
- 3. **Sync your workspace to a known changelist.** Pick a changelist to use as the starting point for your git
142
+ 2. **Sync your workspace to a known changelist.** Pick a changelist to use as the starting point for your git
147
143
  history:
148
144
  ```sh
149
145
  p4 sync //...@12345
150
146
  ```
151
147
 
152
- 4. **Run `git p4son init`.** This can be anywhere inside your Perforce workspace — it doesn't have to be at the
148
+ 3. **Run `git p4son init`.** This can be anywhere inside your Perforce workspace - it doesn't have to be at the
153
149
  root:
154
150
  ```sh
155
151
  cd /path/to/your/workspace # or a subdirectory of it
156
152
  git p4son init
157
153
  ```
158
- The command verifies that you are inside a P4 workspace with clobber enabled, prompts you to select a depot
154
+ The command verifies that you are inside a P4 workspace, prompts you to select a depot
159
155
  root (entire workspace or current directory subtree), runs `git init`, sets up `.gitignore` (copying from
160
156
  `.p4ignore` if available), and creates an initial commit.
161
157
 
162
- 5. **Review `.gitignore`.** Edit the file to ensure build artifacts and other unwanted files are excluded.
158
+ 4. **Review `.gitignore`.** Edit the file to ensure build artifacts and other unwanted files are excluded.
163
159
 
164
- 6. **Add and commit all files manually the first time**
160
+ 5. **Add and commit all files manually the first time**
165
161
  ```sh
166
162
  git add .
167
163
  git commit -m "Initial submit all files"
168
164
  ```
169
165
 
170
- 7. **Run `git p4son sync`** to get your first official sync commit
166
+ 6. **Run `git p4son sync`** to get your first official sync commit
171
167
  ```sh
172
168
  git p4son sync
173
169
  ```
@@ -176,19 +172,41 @@ These steps set up git-p4son in an existing Perforce workspace. You only need to
176
172
  From here, branch off `main` for local development. See the [Usage Example](#usage-example) for a typical
177
173
  workflow.
178
174
 
179
- ### Why clobber?
175
+ ### How sync handles writable files
176
+
177
+ When you switch branches with `git checkout`, git removes the read-only flag on every file it writes. This means
178
+ that when you switch back to `main` for a sync, Perforce may report "Can't clobber writable file" errors. git-p4son
179
+ handles these automatically by classifying each writable file:
180
+
181
+ **writable-unchanged** - The file on disk is identical to the last sync (only the read-only flag was removed
182
+ by git). This is the common case after switching branches. git-p4son detects this by comparing git blob IDs against
183
+ the last sync commit that touched the file, restores the read-only flag, and lets the sync overwrite the file.
184
+
185
+ **writable-changed** - The file on disk differs from the last sync. This happens when you manually edit a file
186
+ on `main` and commit it to git without submitting to Perforce. git-p4son lets the sync overwrite the file and
187
+ includes it in the sync commit (so the commit reflects pure Perforce state), then three-way merges your local
188
+ changes on top of the new Perforce content - similar to a `git rebase`. If the merge is clean, the result appears
189
+ as an unstaged change. If there are conflicts, standard conflict markers are inserted for you to resolve.
180
190
 
181
- When you switch branches with `git checkout`, git overwrites the files that differ between the source and target branch.
182
- In doing so, it always removes the read-only flag - this is just how git works.
191
+ **added both locally and in Perforce** - A file you committed to git is also being added by Perforce (p4 has never
192
+ synced it to your workspace). There is no common baseline, so git-p4son merges the two versions against an empty
193
+ base: identical content merges cleanly, otherwise the file gets a single conflict block containing both full
194
+ versions for you to resolve.
183
195
 
184
- Now imagine one of those files has also been modified in Perforce. The next time `p4 sync` runs, Perforce will complain
185
- that a file was changed outside its control. At that point you have two options: either force-sync the individual file
186
- with `p4 sync -f //path/to/file`, or enable the `clobber` flag on your workspace so that Perforce silently overwrites
187
- writable files.
196
+ **binary files** - Binary files that have local changes cannot be three-way merged. git-p4son restores your local
197
+ version to disk after the sync commit so it shows up in `git status` for you to handle manually.
188
198
 
189
- This is not specific to git-p4son - any workflow that combines git and Perforce in the same workspace will run into it.
190
- And in practice it is harmless: your local changes live on feature branches in git, so when you rebase onto `main` after
191
- a sync, your changes are reapplied on top of the latest Perforce state.
199
+ **added locally, deleted upstream** - A file with local changes in git was deleted in Perforce. git-p4son lets the
200
+ delete stand and warns you - your version remains available in git history.
201
+
202
+ **deleted locally, added upstream** - A file you deleted from git was modified in Perforce. The Perforce version is
203
+ kept in the sync commit. You can delete it again if you want.
204
+
205
+ **ignored** - Files ignored by `.gitignore` are skipped with a warning. These are outside git's domain and should
206
+ be managed manually or via Perforce directly.
207
+
208
+ After syncing, git-p4son prints a summary of how each writable file was handled, and instructs you to review and
209
+ commit any files that need attention.
192
210
 
193
211
  ## Usage
194
212
 
@@ -218,7 +236,7 @@ Initialize a git repository inside a Perforce workspace:
218
236
  git p4son init
219
237
  ```
220
238
 
221
- This command checks preconditions (Perforce workspace, clobber flag), configures the depot root, runs `git init`,
239
+ This command checks preconditions (Perforce workspace), configures the depot root, runs `git init`,
222
240
  sets up `.gitignore`, and creates an initial commit.
223
241
 
224
242
  The depot root determines which part of the Perforce workspace git-p4son syncs. You can choose to sync the entire
@@ -243,7 +261,7 @@ git p4son sync [changelist] [--force]
243
261
  sync to the latest changelist affecting the workspace.
244
262
 
245
263
  **Options:**
246
- - `-f, --force`: Force sync encountered writable files and allow syncing to older changelists.
264
+ - `-f, --force`: Allow syncing to changelists older than the current one.
247
265
 
248
266
  **Examples:**
249
267
  ```sh
@@ -294,7 +312,11 @@ git p4son new -m "New feature" --review -b main
294
312
 
295
313
  ### Update Command
296
314
 
297
- Update an existing Perforce changelist description by replacing the enumerated commit list with the current commits since the base branch. By default also opens changed files for edit.
315
+ Update an existing Perforce changelist description. Commits since the base branch replace their existing
316
+ entries in the enumerated commit list (matched by subject) and new ones are appended; entries outside the
317
+ range are kept and the list is renumbered. So `update -b main` rebuilds the whole list without duplicating
318
+ it, while `update -b HEAD~3` only refreshes the last three entries. By default also opens changed files for
319
+ edit.
298
320
 
299
321
  ```sh
300
322
  git p4son update [changelist] [--base-branch BASE_BRANCH] [--dry-run] [--no-desc] [--no-edit] [--shelve]
@@ -441,16 +463,21 @@ git p4son alias delete myfeature
441
463
 
442
464
  #### alias clean
443
465
 
444
- Interactively review and delete changelist aliases:
466
+ Clean up changelist aliases:
445
467
 
446
468
  ```sh
447
469
  git p4son alias clean
448
470
  ```
449
471
 
450
- This command iterates through each alias, displays it, and prompts for action:
472
+ The command lists all aliases and asks how to proceed:
473
+ - `a` (all): Delete all aliases
474
+ - `i` (interactive): Review each alias one by one
475
+ - `q` (quit): Exit without deleting anything
476
+
477
+ In interactive mode, each alias is displayed in turn with a prompt:
451
478
  - `y` (yes): Delete this alias
452
479
  - `n` (no): Keep this alias
453
- - `a` (all): Delete this and all remaining aliases
480
+ - `a` (all): Delete this and all remaining aliases (handy for keeping the first few and sweeping the rest)
454
481
  - `q` (quit): Stop and keep remaining aliases
455
482
 
456
483
  **Examples:**
@@ -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.2.10"
10
+ __version__ = "0.3.0"
11
11
  __author__ = "Andreas Andersson"
12
12
  __email__ = "andreas@neoboid.com"
@@ -8,6 +8,7 @@ from .changelist_store import (
8
8
  list_changelist_aliases,
9
9
  delete_changelist_alias,
10
10
  )
11
+ from .common import prompt_choice
11
12
  from .log import log
12
13
 
13
14
 
@@ -54,43 +55,27 @@ def alias_delete_command(args: argparse.Namespace) -> int:
54
55
  return 0
55
56
 
56
57
 
57
- def _prompt_delete(prompt: str) -> str | None:
58
- """Prompt until a valid y/n/a/q response is given. Returns None on EOF."""
59
- while True:
60
- try:
61
- response = input(prompt).strip().lower()
62
- except EOFError:
63
- print()
64
- return None
65
-
66
- if response in ('y', 'yes'):
67
- return 'yes'
68
- elif response in ('n', 'no'):
69
- return 'no'
70
- elif response in ('a', 'all'):
71
- return 'all'
72
- elif response in ('q', 'quit'):
73
- return 'quit'
74
- else:
75
- print('Please enter y, n, a, or q')
58
+ def _clean_all(aliases: list[tuple[str, str]], workspace_dir: str) -> None:
59
+ """Delete every alias without further prompting."""
60
+ for name, _changelist in aliases:
61
+ if delete_changelist_alias(name, workspace_dir):
62
+ log.success(f'Deleted "{name}"')
76
63
 
77
64
 
78
- def alias_clean_command(args: argparse.Namespace) -> int:
79
- """Execute the 'alias clean' command with interactive prompts."""
80
- workspace_dir = args.workspace_dir
81
-
82
- aliases = list_changelist_aliases(workspace_dir)
83
- if not aliases:
84
- log.success('No changelist aliases to clean')
85
- return 0
65
+ def _clean_interactive(aliases: list[tuple[str, str]],
66
+ workspace_dir: str) -> None:
67
+ """Review each alias in turn with yes/no/all/quit prompts.
86
68
 
69
+ The all option lets the user keep the first aliases and sweep the rest:
70
+ answer no until the last one worth keeping, then a deletes the
71
+ remainder without further prompts."""
87
72
  response = None
88
73
  for name, changelist in aliases:
89
74
  log.heading(f'{name} -> CL {changelist}')
90
75
 
91
76
  if response != 'all':
92
- response = _prompt_delete(
93
- 'Delete? [y]es / [n]o / [a]ll / [q]uit: ')
77
+ response = prompt_choice(
78
+ 'Delete?', ['yes', 'no', 'all', 'quit'])
94
79
 
95
80
  if response is None or response == 'quit':
96
81
  log.info('Aborting')
@@ -102,6 +87,30 @@ def alias_clean_command(args: argparse.Namespace) -> int:
102
87
  if delete_changelist_alias(name, workspace_dir):
103
88
  log.success('Deleted')
104
89
 
90
+
91
+ def alias_clean_command(args: argparse.Namespace) -> int:
92
+ """Execute the 'alias clean' command."""
93
+ workspace_dir = args.workspace_dir
94
+
95
+ aliases = list_changelist_aliases(workspace_dir)
96
+ if not aliases:
97
+ log.success('No changelist aliases to clean')
98
+ return 0
99
+
100
+ log.info(f'Found {len(aliases)}')
101
+ for name, changelist in aliases:
102
+ log.info(f'{name} -> {changelist}')
103
+
104
+ mode = prompt_choice('Delete?', ['all', 'interactive', 'quit'])
105
+ if mode is None or mode == 'quit':
106
+ log.info('Aborting')
107
+ return 0
108
+
109
+ if mode == 'all':
110
+ _clean_all(aliases, workspace_dir)
111
+ else:
112
+ _clean_interactive(aliases, workspace_dir)
113
+
105
114
  return 0
106
115
 
107
116
 
@@ -19,6 +19,13 @@ RESERVED_KEYWORDS = frozenset({'last-synced', 'branch'})
19
19
  # (Windows disallows trailing dots).
20
20
  _ALIAS_NAME_RE = re.compile(r'^[A-Za-z0-9_-]([A-Za-z0-9._-]*[A-Za-z0-9_-])?$')
21
21
 
22
+ # Windows device names; opening such a path hits the device namespace
23
+ # instead of creating a file, on any drive and regardless of extension.
24
+ _WINDOWS_RESERVED_NAMES = frozenset(
25
+ {'con', 'prn', 'aux', 'nul'}
26
+ | {f'com{i}' for i in range(1, 10)}
27
+ | {f'lpt{i}' for i in range(1, 10)})
28
+
22
29
 
23
30
  def validate_alias_name(name: str) -> str | None:
24
31
  """Return an error message if alias name is invalid, else None."""
@@ -30,6 +37,15 @@ def validate_alias_name(name: str) -> str | None:
30
37
  return (
31
38
  f'Invalid alias name "{name}": must contain only letters, digits, '
32
39
  'hyphens, underscores, and dots, and must not start or end with a dot')
40
+ if name.isdigit():
41
+ # Digit strings are always interpreted as changelist numbers, so
42
+ # such an alias could be created but never referenced.
43
+ return (
44
+ f'Invalid alias name "{name}": an all-digit name would be '
45
+ 'indistinguishable from a changelist number')
46
+ if name.split('.', 1)[0].lower() in _WINDOWS_RESERVED_NAMES:
47
+ return (
48
+ f'Invalid alias name "{name}": reserved device name on Windows')
33
49
  return None
34
50
 
35
51
 
@@ -38,6 +54,18 @@ def _changelists_dir(workspace_dir: str) -> str:
38
54
  return os.path.join(workspace_dir, CONFIG_DIR, 'changelists')
39
55
 
40
56
 
57
+ def _alias_path(name: str, workspace_dir: str) -> str | None:
58
+ """Validate name and return its store path, or None if invalid.
59
+
60
+ Validating on every lookup, not just on save, keeps raw user input
61
+ (e.g. "../../somefile") from escaping the store directory."""
62
+ error = validate_alias_name(name)
63
+ if error:
64
+ log.error(error)
65
+ return None
66
+ return os.path.join(_changelists_dir(workspace_dir), name)
67
+
68
+
41
69
  def alias_exists(name: str, workspace_dir: str) -> bool:
42
70
  """Check whether a changelist alias exists."""
43
71
  alias_path = os.path.join(_changelists_dir(workspace_dir), name)
@@ -46,13 +74,11 @@ def alias_exists(name: str, workspace_dir: str) -> bool:
46
74
 
47
75
  def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force: bool = False) -> bool:
48
76
  """Save a changelist number under a named alias."""
49
- error = validate_alias_name(name)
50
- if error:
51
- log.error(error)
77
+ alias_path = _alias_path(name, workspace_dir)
78
+ if alias_path is None:
52
79
  return False
53
80
 
54
81
  changelists_dir = _changelists_dir(workspace_dir)
55
- alias_path = os.path.join(changelists_dir, name)
56
82
 
57
83
  if os.path.exists(alias_path) and not force:
58
84
  log.error(
@@ -71,7 +97,9 @@ def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force:
71
97
 
72
98
  def load_changelist_alias(name: str, workspace_dir: str) -> str | None:
73
99
  """Load a changelist number from a named alias, or None if not found."""
74
- alias_path = os.path.join(_changelists_dir(workspace_dir), name)
100
+ alias_path = _alias_path(name, workspace_dir)
101
+ if alias_path is None:
102
+ return None
75
103
 
76
104
  if not os.path.exists(alias_path):
77
105
  log.error(f'No changelist alias found: {name}')
@@ -108,7 +136,9 @@ def list_changelist_aliases(workspace_dir: str) -> list[tuple[str, str]]:
108
136
 
109
137
  def delete_changelist_alias(name: str, workspace_dir: str) -> bool:
110
138
  """Delete a changelist alias file."""
111
- alias_path = os.path.join(_changelists_dir(workspace_dir), name)
139
+ alias_path = _alias_path(name, workspace_dir)
140
+ if alias_path is None:
141
+ return False
112
142
 
113
143
  if not os.path.exists(alias_path):
114
144
  log.error(f'No changelist alias found: {name}')
@@ -66,7 +66,7 @@ Examples:
66
66
  'init',
67
67
  help='Initialize a git repository inside a Perforce workspace',
68
68
  description='Set up a new git repository inside a Perforce workspace. '
69
- 'Checks preconditions (P4 workspace, clobber flag), initializes git, '
69
+ 'Checks preconditions (P4 workspace), initializes git, '
70
70
  'sets up .gitignore, and creates an initial commit.'
71
71
  )
72
72
 
@@ -86,10 +86,7 @@ Examples:
86
86
  sync_parser.add_argument(
87
87
  '-f', '--force',
88
88
  action='store_true',
89
- help='Force sync encountered writable files and allow syncing to older changelists. '
90
- 'When clobber is not enabled on your workspace, p4 will fail to sync files that '
91
- 'are read-only. git removes the readonly flag on touched files. Also allows '
92
- 'syncing to changelists older than the current one.'
89
+ help='Allow syncing to changelists older than the current one.'
93
90
  )
94
91
 
95
92
  # New subcommand
@@ -149,6 +146,7 @@ Examples:
149
146
  )
150
147
  new_parser.add_argument(
151
148
  '-s', '--sleep',
149
+ type=int,
152
150
  help='Sleep for the specified number of seconds after the command is done'
153
151
  )
154
152
 
@@ -156,9 +154,11 @@ Examples:
156
154
  update_parser = subparsers.add_parser(
157
155
  'update',
158
156
  help='Update an existing changelist description and open files for edit',
159
- description='Update an existing Perforce changelist description by '
160
- 'replacing the enumerated commit list with the current commits '
161
- 'since the base branch. By default also opens changed files for edit.'
157
+ description='Update an existing Perforce changelist description: '
158
+ 'commits since the base branch replace their existing entries in '
159
+ 'the enumerated commit list and new ones are appended; entries '
160
+ 'outside the range are kept. By default also opens changed files '
161
+ 'for edit.'
162
162
  )
163
163
  update_parser.add_argument(
164
164
  'changelist',
@@ -194,6 +194,7 @@ Examples:
194
194
  )
195
195
  update_parser.add_argument(
196
196
  '-s', '--sleep',
197
+ type=int,
197
198
  help='Sleep for the specified number of seconds after the command is done'
198
199
  )
199
200
 
@@ -268,9 +269,9 @@ Examples:
268
269
  # alias clean
269
270
  alias_subparsers.add_parser(
270
271
  'clean',
271
- help='Interactive cleanup of changelist aliases',
272
- description='Interactively review and delete changelist aliases with '
273
- 'yes/no/all/quit prompts'
272
+ help='Clean up changelist aliases',
273
+ description='List all changelist aliases, then delete all of them '
274
+ 'or review each one interactively with yes/no/all/quit prompts'
274
275
  )
275
276
 
276
277
  # Review subcommand
@@ -468,7 +469,7 @@ def main() -> int:
468
469
  exit_code = run_command(args)
469
470
 
470
471
  if exit_code == 0 and getattr(args, 'sleep', None) is not None:
471
- seconds = int(args.sleep)
472
+ seconds = args.sleep
472
473
  log.heading(f'Sleeping for {seconds} seconds')
473
474
  time.sleep(seconds)
474
475
  log.success('awake again')