git-p4son 0.2.11__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.
- {git_p4son-0.2.11 → git_p4son-0.3.0}/PKG-INFO +42 -24
- {git_p4son-0.2.11 → git_p4son-0.3.0}/README.md +41 -23
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/__init__.py +1 -1
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/alias.py +3 -27
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/cli.py +2 -5
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/common.py +25 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/config.py +17 -10
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/git.py +193 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/init.py +0 -11
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/perforce.py +75 -8
- git_p4son-0.3.0/git_p4son/state.py +56 -0
- git_p4son-0.3.0/git_p4son/sync.py +699 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son.egg-info/PKG-INFO +42 -24
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son.egg-info/SOURCES.txt +3 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/pyproject.toml +1 -1
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_alias.py +4 -3
- git_p4son-0.3.0/tests/test_git.py +407 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_init.py +0 -11
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_lib_edit.py +25 -4
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_perforce.py +107 -1
- git_p4son-0.3.0/tests/test_state.py +64 -0
- git_p4son-0.3.0/tests/test_sync.py +1205 -0
- git_p4son-0.2.11/git_p4son/sync.py +0 -208
- git_p4son-0.2.11/tests/test_sync.py +0 -497
- {git_p4son-0.2.11 → git_p4son-0.3.0}/LICENSE +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/__main__.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/changelist_store.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/complete.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/completions/_git-p4son +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/completions/git-p4son.bash +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/completions/git-p4son.ps1 +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/hooks.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/lib.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/list_changes.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/log.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/new.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/review.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son/update.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son.egg-info/dependency_links.txt +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son.egg-info/entry_points.txt +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/git_p4son.egg-info/top_level.txt +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/setup.cfg +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_changelist_store.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_cli.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_common.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_complete.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_config.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_hooks.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_lib_changelist.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_lib_review.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_list_changes.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_log.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/tests/test_new.py +0 -0
- {git_p4son-0.2.11 → git_p4son-0.3.0}/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
|
+
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
181
|
+
4. **Review `.gitignore`.** Edit the file to ensure build artifacts and other unwanted files are excluded.
|
|
186
182
|
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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.
|
|
213
|
+
|
|
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.
|
|
218
|
+
|
|
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.
|
|
221
|
+
|
|
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.
|
|
203
224
|
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
206
227
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
with `p4 sync -f //path/to/file`, or enable the `clobber` flag on your workspace so that Perforce silently overwrites
|
|
210
|
-
writable files.
|
|
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.
|
|
211
230
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
a sync, your changes are reapplied on top of the latest Perforce state.
|
|
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
|
|
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`:
|
|
287
|
+
- `-f, --force`: Allow syncing to changelists older than the current one.
|
|
270
288
|
|
|
271
289
|
**Examples:**
|
|
272
290
|
```sh
|
|
@@ -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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
158
|
+
4. **Review `.gitignore`.** Edit the file to ensure build artifacts and other unwanted files are excluded.
|
|
163
159
|
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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.
|
|
190
|
+
|
|
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.
|
|
195
|
+
|
|
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.
|
|
198
|
+
|
|
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.
|
|
180
201
|
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
183
204
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
with `p4 sync -f //path/to/file`, or enable the `clobber` flag on your workspace so that Perforce silently overwrites
|
|
187
|
-
writable files.
|
|
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.
|
|
188
207
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
a sync, your changes are reapplied on top of the latest Perforce state.
|
|
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
|
|
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`:
|
|
264
|
+
- `-f, --force`: Allow syncing to changelists older than the current one.
|
|
247
265
|
|
|
248
266
|
**Examples:**
|
|
249
267
|
```sh
|
|
@@ -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,31 +55,6 @@ def alias_delete_command(args: argparse.Namespace) -> int:
|
|
|
54
55
|
return 0
|
|
55
56
|
|
|
56
57
|
|
|
57
|
-
def _prompt_choice(prefix: str, options: list[str]) -> str | None:
|
|
58
|
-
"""Prompt until the response matches an option or its first letter.
|
|
59
|
-
|
|
60
|
-
The choices are rendered from the options, e.g. "[y]es / [n]o", so the
|
|
61
|
-
prompt and the accepted keys cannot drift apart. Options must have
|
|
62
|
-
unique first letters. Returns the chosen option, or None on EOF.
|
|
63
|
-
"""
|
|
64
|
-
shorthands = {option[0]: option for option in options}
|
|
65
|
-
rendered = ' / '.join(f'[{option[0]}]{option[1:]}' for option in options)
|
|
66
|
-
keys = list(shorthands)
|
|
67
|
-
while True:
|
|
68
|
-
try:
|
|
69
|
-
response = input(f'{prefix} {rendered}: ').strip().lower()
|
|
70
|
-
except EOFError:
|
|
71
|
-
print()
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
if response in shorthands:
|
|
75
|
-
return shorthands[response]
|
|
76
|
-
elif response in options:
|
|
77
|
-
return response
|
|
78
|
-
else:
|
|
79
|
-
print('Please enter ' + ', '.join(keys[:-1]) + f' or {keys[-1]}')
|
|
80
|
-
|
|
81
|
-
|
|
82
58
|
def _clean_all(aliases: list[tuple[str, str]], workspace_dir: str) -> None:
|
|
83
59
|
"""Delete every alias without further prompting."""
|
|
84
60
|
for name, _changelist in aliases:
|
|
@@ -98,7 +74,7 @@ def _clean_interactive(aliases: list[tuple[str, str]],
|
|
|
98
74
|
log.heading(f'{name} -> CL {changelist}')
|
|
99
75
|
|
|
100
76
|
if response != 'all':
|
|
101
|
-
response =
|
|
77
|
+
response = prompt_choice(
|
|
102
78
|
'Delete?', ['yes', 'no', 'all', 'quit'])
|
|
103
79
|
|
|
104
80
|
if response is None or response == 'quit':
|
|
@@ -125,7 +101,7 @@ def alias_clean_command(args: argparse.Namespace) -> int:
|
|
|
125
101
|
for name, changelist in aliases:
|
|
126
102
|
log.info(f'{name} -> {changelist}')
|
|
127
103
|
|
|
128
|
-
mode =
|
|
104
|
+
mode = prompt_choice('Delete?', ['all', 'interactive', 'quit'])
|
|
129
105
|
if mode is None or mode == 'quit':
|
|
130
106
|
log.info('Aborting')
|
|
131
107
|
return 0
|
|
@@ -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
|
|
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='
|
|
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
|
|
@@ -36,6 +36,31 @@ def branch_to_alias(branch_name: str) -> str:
|
|
|
36
36
|
return branch_name.replace('/', '-')
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def prompt_choice(prefix: str, options: list[str]) -> str | None:
|
|
40
|
+
"""Prompt until the response matches an option or its first letter.
|
|
41
|
+
|
|
42
|
+
The choices are rendered from the options, e.g. "[y]es / [n]o", so the
|
|
43
|
+
prompt and the accepted keys cannot drift apart. Options must have
|
|
44
|
+
unique first letters. Returns the chosen option, or None on EOF.
|
|
45
|
+
"""
|
|
46
|
+
shorthands = {option[0]: option for option in options}
|
|
47
|
+
rendered = ' / '.join(f'[{option[0]}]{option[1:]}' for option in options)
|
|
48
|
+
keys = list(shorthands)
|
|
49
|
+
while True:
|
|
50
|
+
try:
|
|
51
|
+
response = input(f'{prefix} {rendered}: ').strip().lower()
|
|
52
|
+
except EOFError:
|
|
53
|
+
print()
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
if response in shorthands:
|
|
57
|
+
return shorthands[response]
|
|
58
|
+
elif response in options:
|
|
59
|
+
return response
|
|
60
|
+
else:
|
|
61
|
+
print('Please enter ' + ', '.join(keys[:-1]) + f' or {keys[-1]}')
|
|
62
|
+
|
|
63
|
+
|
|
39
64
|
def _path_module_for(*paths: str):
|
|
40
65
|
"""Choose a path module that matches the given path strings."""
|
|
41
66
|
if any('\\' in path or re.match(r'^[A-Za-z]:', path) for path in paths):
|
|
@@ -18,15 +18,28 @@ def config_path(workspace_dir: str) -> str:
|
|
|
18
18
|
return os.path.join(workspace_dir, CONFIG_DIR, 'config.toml')
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
22
|
-
"""Read and parse
|
|
23
|
-
path = config_path(workspace_dir)
|
|
21
|
+
def load_toml(path: str) -> dict:
|
|
22
|
+
"""Read and parse a TOML file. Returns empty dict if missing."""
|
|
24
23
|
if not os.path.exists(path):
|
|
25
24
|
return {}
|
|
26
25
|
with open(path, 'rb') as f:
|
|
27
26
|
return tomllib.load(f)
|
|
28
27
|
|
|
29
28
|
|
|
29
|
+
def write_toml(path: str, data: dict) -> None:
|
|
30
|
+
"""Write data to a TOML file, creating parent directories as needed."""
|
|
31
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
32
|
+
# tomllib reads strict UTF-8, so write UTF-8 explicitly.
|
|
33
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
34
|
+
for section, values in data.items():
|
|
35
|
+
_write_table(f, _format_key(section), values)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(workspace_dir: str) -> dict:
|
|
39
|
+
"""Read and parse the config file. Returns empty dict if missing."""
|
|
40
|
+
return load_toml(config_path(workspace_dir))
|
|
41
|
+
|
|
42
|
+
|
|
30
43
|
def save_config(workspace_dir: str, config: dict) -> None:
|
|
31
44
|
"""Merge config into the config file, section by section.
|
|
32
45
|
|
|
@@ -36,13 +49,7 @@ def save_config(workspace_dir: str, config: dict) -> None:
|
|
|
36
49
|
merged = load_config(workspace_dir)
|
|
37
50
|
for section, values in config.items():
|
|
38
51
|
merged.setdefault(section, {}).update(values)
|
|
39
|
-
|
|
40
|
-
path = config_path(workspace_dir)
|
|
41
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
42
|
-
# tomllib reads strict UTF-8, so write UTF-8 explicitly.
|
|
43
|
-
with open(path, 'w', encoding='utf-8') as f:
|
|
44
|
-
for section, values in merged.items():
|
|
45
|
-
_write_table(f, _format_key(section), values)
|
|
52
|
+
write_toml(config_path(workspace_dir), merged)
|
|
46
53
|
|
|
47
54
|
|
|
48
55
|
def _format_string(value: str) -> str:
|
|
@@ -234,6 +234,199 @@ def get_commit_subjects_since(base_branch: str, workspace_dir: str) -> list[str]
|
|
|
234
234
|
return subjects
|
|
235
235
|
|
|
236
236
|
|
|
237
|
+
# --- tracking ---
|
|
238
|
+
|
|
239
|
+
def get_tracked_files(filepaths: list[str], workspace_dir: str) -> set[str]:
|
|
240
|
+
"""Return the subset of filepaths that are tracked by git, batched.
|
|
241
|
+
|
|
242
|
+
Tracking status decides whether a file is git's to manage: a tracked
|
|
243
|
+
file matching a .gitignore pattern (common when .gitignore was copied
|
|
244
|
+
from .p4ignore) is still tracked. Returned paths are the input paths."""
|
|
245
|
+
if not filepaths:
|
|
246
|
+
return set()
|
|
247
|
+
by_git_path: dict[str, str] = {}
|
|
248
|
+
for filepath in filepaths:
|
|
249
|
+
git_path = normalize_workspace_path(filepath, workspace_dir)
|
|
250
|
+
if git_path is not None:
|
|
251
|
+
by_git_path[git_path] = filepath
|
|
252
|
+
tracked: set[str] = set()
|
|
253
|
+
chunks = _chunk_paths_by_length(
|
|
254
|
+
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])
|
|
264
|
+
return tracked
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --- file retrieval ---
|
|
268
|
+
|
|
269
|
+
def get_file_at_commit(filepath: str, commit: str,
|
|
270
|
+
workspace_dir: str) -> bytes | None:
|
|
271
|
+
"""Retrieve file content at a specific commit. Returns None if the file doesn't exist."""
|
|
272
|
+
# Git uses forward slashes in tree paths, even on Windows
|
|
273
|
+
git_path = filepath.replace('\\', '/')
|
|
274
|
+
result = run(['git', 'show', f'{commit}:{git_path}'],
|
|
275
|
+
cwd=workspace_dir, text=False, fail_on_returncode=False)
|
|
276
|
+
if result.returncode != 0:
|
|
277
|
+
return None
|
|
278
|
+
return result.stdout
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_blob_oids(items: list[tuple[str, str]],
|
|
282
|
+
workspace_dir: str) -> dict[tuple[str, str], str | None]:
|
|
283
|
+
"""Blob OIDs for (commit, filepath) pairs, resolved in one git call.
|
|
284
|
+
|
|
285
|
+
Returns a mapping of (commit, filepath) to OID, or None when the file
|
|
286
|
+
doesn't exist at that commit. Equal OIDs mean byte-identical content
|
|
287
|
+
(git content-addresses blobs), so this answers "did the content change?"
|
|
288
|
+
without transferring the blobs."""
|
|
289
|
+
if not items:
|
|
290
|
+
return {}
|
|
291
|
+
queries = []
|
|
292
|
+
for commit, filepath in items:
|
|
293
|
+
# Git uses forward slashes in tree paths, even on Windows
|
|
294
|
+
git_path = filepath.replace('\\', '/')
|
|
295
|
+
queries.append(f'{commit}:{git_path}')
|
|
296
|
+
# cat-file --batch-check reads object names from stdin and emits exactly
|
|
297
|
+
# one line per input line, so results map back to items by position.
|
|
298
|
+
result = run(['git', 'cat-file', '--batch-check'], cwd=workspace_dir,
|
|
299
|
+
input='\n'.join(queries) + '\n')
|
|
300
|
+
oids: dict[tuple[str, str], str | None] = {}
|
|
301
|
+
for item, line in zip(items, result.stdout):
|
|
302
|
+
# Found objects print "<oid> <type> <size>"; anything else
|
|
303
|
+
# (missing, ambiguous) means no blob at that commit.
|
|
304
|
+
parts = line.split()
|
|
305
|
+
if len(parts) == 3 and parts[2].isdigit():
|
|
306
|
+
oids[item] = parts[0]
|
|
307
|
+
else:
|
|
308
|
+
oids[item] = None
|
|
309
|
+
return oids
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_head_commit(workspace_dir: str) -> str:
|
|
313
|
+
"""Return the SHA of HEAD."""
|
|
314
|
+
result = run(['git', 'rev-parse', 'HEAD'], cwd=workspace_dir)
|
|
315
|
+
return result.stdout[0].strip()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Substring identifying git-p4son sync commit subjects.
|
|
319
|
+
SYNC_SUBJECT_MARKER = ': p4 sync //'
|
|
320
|
+
|
|
321
|
+
# Conservative limit for pathspec arguments per git invocation; Windows
|
|
322
|
+
# caps the whole command line at 32767 characters.
|
|
323
|
+
_PATHSPEC_LENGTH_BUDGET = 20000
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _chunk_paths_by_length(paths: list[str], budget: int) -> list[list[str]]:
|
|
327
|
+
"""Split paths into chunks whose total argument length stays in budget."""
|
|
328
|
+
chunks: list[list[str]] = []
|
|
329
|
+
chunk: list[str] = []
|
|
330
|
+
used = 0
|
|
331
|
+
for path in paths:
|
|
332
|
+
cost = len(path) + 1
|
|
333
|
+
if chunk and used + cost > budget:
|
|
334
|
+
chunks.append(chunk)
|
|
335
|
+
chunk = []
|
|
336
|
+
used = 0
|
|
337
|
+
chunk.append(path)
|
|
338
|
+
used += cost
|
|
339
|
+
if chunk:
|
|
340
|
+
chunks.append(chunk)
|
|
341
|
+
return chunks
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def find_base_commits(filepaths: list[str], before_commit: str,
|
|
345
|
+
workspace_dir: str) -> dict[str, str | None]:
|
|
346
|
+
"""Baseline commit for each repo-relative path, batched.
|
|
347
|
+
|
|
348
|
+
The baseline is the most recent sync commit reachable from before_commit
|
|
349
|
+
(inclusive) that touched the file, falling back to the most recent commit
|
|
350
|
+
that added it when no sync commit ever touched it (e.g. files brought in
|
|
351
|
+
via an initial bulk import committed with a non-sync subject; if the file
|
|
352
|
+
was deleted and re-added, the most recent add starts the current
|
|
353
|
+
lineage), or None when neither exists.
|
|
354
|
+
|
|
355
|
+
All paths are resolved in a single history walk per pathspec chunk
|
|
356
|
+
instead of one git log per file."""
|
|
357
|
+
result: dict[str, str | None] = {}
|
|
358
|
+
if not filepaths:
|
|
359
|
+
return result
|
|
360
|
+
# Git uses forward slashes in tree paths, even on Windows
|
|
361
|
+
by_git_path = {fp.replace('\\', '/'): fp for fp in filepaths}
|
|
362
|
+
chunks = _chunk_paths_by_length(
|
|
363
|
+
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
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _find_base_commits_chunk(git_paths: list[str], before_commit: str,
|
|
373
|
+
workspace_dir: str) -> dict[str, str | None]:
|
|
374
|
+
"""One newest-first history walk resolving baselines for git_paths.
|
|
375
|
+
|
|
376
|
+
The first sync commit seen touching a path is its baseline (the most
|
|
377
|
+
recent one). The first add seen is remembered as the fallback for paths
|
|
378
|
+
no sync commit ever touched. core.quotePath is disabled so non-ASCII
|
|
379
|
+
paths in --name-status output match the input verbatim."""
|
|
380
|
+
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)
|
|
386
|
+
if res.returncode != 0:
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
remaining = set(git_paths)
|
|
390
|
+
fallback_add: dict[str, str] = {}
|
|
391
|
+
current_sha = ''
|
|
392
|
+
current_is_sync = False
|
|
393
|
+
for line in res.stdout:
|
|
394
|
+
if line.startswith('\x01'):
|
|
395
|
+
_, current_sha, subject = line.split('\x01', 2)
|
|
396
|
+
current_is_sync = SYNC_SUBJECT_MARKER in subject
|
|
397
|
+
continue
|
|
398
|
+
status, sep, path = line.partition('\t')
|
|
399
|
+
if not sep or path not in remaining:
|
|
400
|
+
continue
|
|
401
|
+
if current_is_sync:
|
|
402
|
+
result[path] = current_sha
|
|
403
|
+
remaining.discard(path)
|
|
404
|
+
elif status.startswith('A') and path not in fallback_add:
|
|
405
|
+
fallback_add[path] = current_sha
|
|
406
|
+
for path in remaining:
|
|
407
|
+
result[path] = fallback_add.get(path)
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# --- merge ---
|
|
412
|
+
|
|
413
|
+
def merge_file(current_path: str, base_path: str,
|
|
414
|
+
other_path: str) -> tuple[bool, bytes]:
|
|
415
|
+
"""Three-way merge using git merge-file.
|
|
416
|
+
|
|
417
|
+
All three inputs are file paths read by git directly. Returns
|
|
418
|
+
(clean, merged_content) where clean is True if no conflicts.
|
|
419
|
+
"""
|
|
420
|
+
result = run(
|
|
421
|
+
['git', 'merge-file', '-p',
|
|
422
|
+
'--marker-size=7',
|
|
423
|
+
'-L', 'Perforce', '-L', 'base', '-L', 'local',
|
|
424
|
+
current_path, base_path, other_path],
|
|
425
|
+
text=False, fail_on_returncode=False)
|
|
426
|
+
# git merge-file -p: exit 0 = clean, >0 = conflicts (count), <0 = error
|
|
427
|
+
return (result.returncode == 0, result.stdout)
|
|
428
|
+
|
|
429
|
+
|
|
237
430
|
# --- editor ---
|
|
238
431
|
|
|
239
432
|
def resolve_editor(workspace_dir: str) -> str | None:
|
|
@@ -145,17 +145,6 @@ def init_command(args: argparse.Namespace) -> int:
|
|
|
145
145
|
return 1
|
|
146
146
|
log.success(spec.name)
|
|
147
147
|
|
|
148
|
-
log.heading('Checking clobber flag')
|
|
149
|
-
if spec.clobber:
|
|
150
|
-
log.success('clobber is enabled')
|
|
151
|
-
else:
|
|
152
|
-
log.error(
|
|
153
|
-
f'clobber is not enabled on workspace "{spec.name}".\n'
|
|
154
|
-
' Git removes read-only flags when switching branches, so p4 sync\n'
|
|
155
|
-
' will fail to overwrite those files unless clobber is enabled.\n'
|
|
156
|
-
f' Edit "{spec.name}" in P4V to set the clobber flag.')
|
|
157
|
-
return 1
|
|
158
|
-
|
|
159
148
|
if not _configure_depot_root(spec.name, cwd, spec.root):
|
|
160
149
|
return 1
|
|
161
150
|
|