git-p4son 0.4.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {git_p4son-0.4.0 → git_p4son-0.4.1}/PKG-INFO +8 -2
- {git_p4son-0.4.0 → git_p4son-0.4.1}/README.md +7 -1
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/__init__.py +1 -1
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/changelist_store.py +2 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/cli.py +9 -2
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/common.py +19 -7
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/config.py +22 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/perforce.py +18 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/review.py +2 -0
- git_p4son-0.4.1/git_p4son/state.py +38 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/sync.py +11 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/PKG-INFO +8 -2
- {git_p4son-0.4.0 → git_p4son-0.4.1}/pyproject.toml +1 -1
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_changelist_store.py +7 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_cli.py +11 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_common.py +19 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_complete.py +2 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_config.py +42 -0
- git_p4son-0.4.1/tests/test_state.py +39 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_sync.py +84 -0
- git_p4son-0.4.0/git_p4son/state.py +0 -56
- git_p4son-0.4.0/tests/test_state.py +0 -64
- {git_p4son-0.4.0 → git_p4son-0.4.1}/LICENSE +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/__main__.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/alias.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/complete.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/completions/_git-p4son +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/completions/git-p4son.bash +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/completions/git-p4son.ps1 +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/depot.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/git.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/hooks.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/init.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/lib.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/list_changes.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/log.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/new.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/sync_split.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/update.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son/writable.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/SOURCES.txt +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/dependency_links.txt +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/entry_points.txt +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/git_p4son.egg-info/top_level.txt +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/setup.cfg +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_alias.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_depot.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_git.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_hooks.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_init.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_changelist.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_edit.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_revert.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_lib_review.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_list_changes.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_log.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_new.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_perforce.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_review.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_sync_split.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_update.py +0 -0
- {git_p4son-0.4.0 → git_p4son-0.4.1}/tests/test_writable.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-p4son
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Utility for keeping a Perforce workspace and local git repo in sync
|
|
5
5
|
Author-email: Andreas Andersson <andreas@neoboid.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -277,6 +277,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
|
|
|
277
277
|
workspace or just the directory's subtree where the git root is placed. The selection is saved in
|
|
278
278
|
`.git-p4son/config.toml` and used by all subsequent commands.
|
|
279
279
|
|
|
280
|
+
Nothing in `.git-p4son/` belongs in git. The folder has its own `.gitignore` that ignores everything in it, so there
|
|
281
|
+
is no need to list it in the workspace `.gitignore`.
|
|
282
|
+
|
|
280
283
|
The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
|
|
281
284
|
`root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
|
|
282
285
|
the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
|
|
@@ -295,7 +298,7 @@ saves the answer; run `git p4son writable apply` once your files are committed t
|
|
|
295
298
|
Sync local git repository with a Perforce workspace:
|
|
296
299
|
|
|
297
300
|
```sh
|
|
298
|
-
git p4son sync [changelist ...] [--force]
|
|
301
|
+
git p4son sync [changelist ...] [--force] [--dry-run]
|
|
299
302
|
```
|
|
300
303
|
|
|
301
304
|
**Arguments:**
|
|
@@ -306,6 +309,8 @@ git p4son sync [changelist ...] [--force]
|
|
|
306
309
|
|
|
307
310
|
**Options:**
|
|
308
311
|
- `-f, --force`: Allow syncing to changelists older than the current one.
|
|
312
|
+
- `-n, --dry-run`: Print the resolved sync sequence without syncing. The arguments are validated as for a
|
|
313
|
+
real sync, but nothing else runs: no clean-workspace checks, no hooks, and no prompts.
|
|
309
314
|
|
|
310
315
|
**Examples:**
|
|
311
316
|
```sh
|
|
@@ -316,6 +321,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
|
|
|
316
321
|
git p4son sync 123 156 head # sync 123, 156, then the latest changelist
|
|
317
322
|
git p4son sync last-synced
|
|
318
323
|
git p4son sync 12345 --force
|
|
324
|
+
git p4son sync --dry-run # show the changelists a sync to latest would visit
|
|
319
325
|
```
|
|
320
326
|
|
|
321
327
|
#### pre-sync hook
|
|
@@ -254,6 +254,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
|
|
|
254
254
|
workspace or just the directory's subtree where the git root is placed. The selection is saved in
|
|
255
255
|
`.git-p4son/config.toml` and used by all subsequent commands.
|
|
256
256
|
|
|
257
|
+
Nothing in `.git-p4son/` belongs in git. The folder has its own `.gitignore` that ignores everything in it, so there
|
|
258
|
+
is no need to list it in the workspace `.gitignore`.
|
|
259
|
+
|
|
257
260
|
The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
|
|
258
261
|
`root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
|
|
259
262
|
the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
|
|
@@ -272,7 +275,7 @@ saves the answer; run `git p4son writable apply` once your files are committed t
|
|
|
272
275
|
Sync local git repository with a Perforce workspace:
|
|
273
276
|
|
|
274
277
|
```sh
|
|
275
|
-
git p4son sync [changelist ...] [--force]
|
|
278
|
+
git p4son sync [changelist ...] [--force] [--dry-run]
|
|
276
279
|
```
|
|
277
280
|
|
|
278
281
|
**Arguments:**
|
|
@@ -283,6 +286,8 @@ git p4son sync [changelist ...] [--force]
|
|
|
283
286
|
|
|
284
287
|
**Options:**
|
|
285
288
|
- `-f, --force`: Allow syncing to changelists older than the current one.
|
|
289
|
+
- `-n, --dry-run`: Print the resolved sync sequence without syncing. The arguments are validated as for a
|
|
290
|
+
real sync, but nothing else runs: no clean-workspace checks, no hooks, and no prompts.
|
|
286
291
|
|
|
287
292
|
**Examples:**
|
|
288
293
|
```sh
|
|
@@ -293,6 +298,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
|
|
|
293
298
|
git p4son sync 123 156 head # sync 123, 156, then the latest changelist
|
|
294
299
|
git p4son sync last-synced
|
|
295
300
|
git p4son sync 12345 --force
|
|
301
|
+
git p4son sync --dry-run # show the changelists a sync to latest would visit
|
|
296
302
|
```
|
|
297
303
|
|
|
298
304
|
#### pre-sync hook
|
|
@@ -8,6 +8,7 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
|
|
10
10
|
from . import CONFIG_DIR
|
|
11
|
+
from .config import ensure_config_dir
|
|
11
12
|
from .log import log
|
|
12
13
|
|
|
13
14
|
|
|
@@ -85,6 +86,7 @@ def save_changelist_alias(name: str, changelist: str, workspace_dir: str, force:
|
|
|
85
86
|
f'Alias "{name}" already exists (use -f/--force to overwrite)')
|
|
86
87
|
return False
|
|
87
88
|
|
|
89
|
+
ensure_config_dir(workspace_dir)
|
|
88
90
|
if not os.path.isdir(changelists_dir):
|
|
89
91
|
log.info(f'Creating {changelists_dir}')
|
|
90
92
|
os.makedirs(changelists_dir, exist_ok=True)
|
|
@@ -38,6 +38,7 @@ Examples:
|
|
|
38
38
|
git-p4son sync 123 156 178 # Sync each changelist in sequence, one commit each
|
|
39
39
|
git-p4son sync 123 156 head # Sync 123, 156, then the latest changelist
|
|
40
40
|
git-p4son sync last-synced # Re-sync the last synced changelist
|
|
41
|
+
git-p4son sync --dry-run # Show the changelists a sync would visit
|
|
41
42
|
git-p4son sync-split # Sync to latest, your own changelists split out
|
|
42
43
|
git-p4son sync-split 12345 # Same, but stop at changelist 12345
|
|
43
44
|
git-p4son sync-split -u alice -u bob # Split out alice's and bob's changelists
|
|
@@ -103,6 +104,11 @@ Examples:
|
|
|
103
104
|
action='store_true',
|
|
104
105
|
help='Allow syncing to changelists older than the current one.'
|
|
105
106
|
)
|
|
107
|
+
sync_parser.add_argument(
|
|
108
|
+
'-n', '--dry-run',
|
|
109
|
+
action='store_true',
|
|
110
|
+
help='Print the resolved sync sequence without syncing'
|
|
111
|
+
)
|
|
106
112
|
|
|
107
113
|
# Sync-split subcommand
|
|
108
114
|
sync_split_parser = subparsers.add_parser(
|
|
@@ -408,10 +414,11 @@ Examples:
|
|
|
408
414
|
help='Print the directory instead of the full file path'
|
|
409
415
|
)
|
|
410
416
|
|
|
411
|
-
# Hidden _sequence-editor subcommand (used internally by review)
|
|
417
|
+
# Hidden _sequence-editor subcommand (used internally by review). It is
|
|
418
|
+
# given no help: argparse lists a subcommand with help=SUPPRESS anyway,
|
|
419
|
+
# as "==SUPPRESS==", but leaves one without help out of the list.
|
|
412
420
|
seq_editor_parser = subparsers.add_parser(
|
|
413
421
|
'_sequence-editor',
|
|
414
|
-
help=argparse.SUPPRESS,
|
|
415
422
|
)
|
|
416
423
|
seq_editor_parser.add_argument(
|
|
417
424
|
'filename',
|
|
@@ -256,6 +256,16 @@ def enqueue_lines(stream: IO[str], output_queue: queue.Queue[str]) -> None:
|
|
|
256
256
|
output_queue.put(line.rstrip())
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
def _terminate(process: subprocess.Popen) -> None:
|
|
260
|
+
"""Terminate a subprocess, killing it if it does not exit in time."""
|
|
261
|
+
process.terminate()
|
|
262
|
+
try:
|
|
263
|
+
process.wait(timeout=5)
|
|
264
|
+
except subprocess.TimeoutExpired:
|
|
265
|
+
log.error("Subprocess did not terminate in time. Forcing kill...")
|
|
266
|
+
process.kill()
|
|
267
|
+
|
|
268
|
+
|
|
259
269
|
def run_with_output(command: list[str], cwd: str = '.',
|
|
260
270
|
on_output: Callable[..., None] | None = None,
|
|
261
271
|
env: dict[str, str] | None = None) -> RunResult:
|
|
@@ -268,6 +278,8 @@ def run_with_output(command: list[str], cwd: str = '.',
|
|
|
268
278
|
on_output: Callback function for processing output lines
|
|
269
279
|
If set the funciton will be called with each
|
|
270
280
|
line and stream (stdout/stderr) as they are written.
|
|
281
|
+
An exception raised from it terminates the command
|
|
282
|
+
and propagates.
|
|
271
283
|
env: Optional environment variables to add or override
|
|
272
284
|
|
|
273
285
|
Returns:
|
|
@@ -352,14 +364,14 @@ def run_with_output(command: list[str], cwd: str = '.',
|
|
|
352
364
|
except KeyboardInterrupt:
|
|
353
365
|
log.stop_spinner()
|
|
354
366
|
log.error("CTRL-C pressed, terminate subprocess")
|
|
355
|
-
process
|
|
356
|
-
try:
|
|
357
|
-
process.wait(timeout=5)
|
|
358
|
-
except subprocess.TimeoutExpired:
|
|
359
|
-
log.error(
|
|
360
|
-
"Subprocess did not terminate in time. Forcing kill...")
|
|
361
|
-
process.kill()
|
|
367
|
+
_terminate(process)
|
|
362
368
|
sys.exit(1)
|
|
369
|
+
except Exception:
|
|
370
|
+
# on_output raising aborts the command: stop the subprocess
|
|
371
|
+
# rather than letting it run on unobserved.
|
|
372
|
+
log.stop_spinner()
|
|
373
|
+
_terminate(process)
|
|
374
|
+
raise
|
|
363
375
|
|
|
364
376
|
log.stop_spinner()
|
|
365
377
|
|
|
@@ -13,6 +13,27 @@ from . import CONFIG_DIR
|
|
|
13
13
|
_BARE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def ensure_config_dir(workspace_dir: str) -> None:
|
|
17
|
+
"""Create the config dir with a .gitignore that ignores everything in it.
|
|
18
|
+
|
|
19
|
+
Nothing in the config dir belongs in git. A .gitignore of its own keeps
|
|
20
|
+
it out without touching the workspace .gitignore, which may be checked
|
|
21
|
+
into Perforce. The file is rewritten when its content differs, which
|
|
22
|
+
also replaces the state.toml-only ignore file of earlier versions."""
|
|
23
|
+
config_dir = os.path.join(workspace_dir, CONFIG_DIR)
|
|
24
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
25
|
+
gitignore = os.path.join(config_dir, '.gitignore')
|
|
26
|
+
content = '*\n'
|
|
27
|
+
try:
|
|
28
|
+
with open(gitignore, encoding='utf-8') as f:
|
|
29
|
+
if f.read() == content:
|
|
30
|
+
return
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
pass
|
|
33
|
+
with open(gitignore, 'w', encoding='utf-8') as f:
|
|
34
|
+
f.write(content)
|
|
35
|
+
|
|
36
|
+
|
|
16
37
|
def config_path(workspace_dir: str) -> str:
|
|
17
38
|
"""Return the path to the config file."""
|
|
18
39
|
return os.path.join(workspace_dir, CONFIG_DIR, 'config.toml')
|
|
@@ -49,6 +70,7 @@ def save_config(workspace_dir: str, config: dict) -> None:
|
|
|
49
70
|
merged = load_config(workspace_dir)
|
|
50
71
|
for section, values in config.items():
|
|
51
72
|
merged.setdefault(section, {}).update(values)
|
|
73
|
+
ensure_config_dir(workspace_dir)
|
|
52
74
|
write_toml(config_path(workspace_dir), merged)
|
|
53
75
|
|
|
54
76
|
|
|
@@ -452,6 +452,16 @@ def parse_p4_sync_line(line: str) -> tuple[str | None, str | None]:
|
|
|
452
452
|
return (None, None)
|
|
453
453
|
|
|
454
454
|
|
|
455
|
+
# p4 writes a synced file to a temp file and renames it into place. On
|
|
456
|
+
# Windows the rename fails when another process holds the target open, e.g.
|
|
457
|
+
# an editor with the asset loaded, leaving the file unsynced.
|
|
458
|
+
_RENAME_FAILED_PREFIX = 'rename: failed to rename '
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class P4SyncAbortError(CommandError):
|
|
462
|
+
"""Raised to stop a p4 sync that can no longer succeed."""
|
|
463
|
+
|
|
464
|
+
|
|
455
465
|
class P4SyncOutputProcessor:
|
|
456
466
|
"""Process p4 sync output in real-time."""
|
|
457
467
|
|
|
@@ -465,6 +475,14 @@ class P4SyncOutputProcessor:
|
|
|
465
475
|
log.info('all files up to date')
|
|
466
476
|
return
|
|
467
477
|
|
|
478
|
+
if line.startswith(_RENAME_FAILED_PREFIX):
|
|
479
|
+
log.error(line)
|
|
480
|
+
raise P4SyncAbortError(
|
|
481
|
+
'Aborting sync: p4 could not replace a file, most likely '
|
|
482
|
+
'because another process has it open. The workspace is left '
|
|
483
|
+
'partially synced: close that process, then commit or '
|
|
484
|
+
'restore the synced files before syncing again.')
|
|
485
|
+
|
|
468
486
|
mode, filename = parse_p4_sync_line(line)
|
|
469
487
|
if not mode or not filename:
|
|
470
488
|
log.warning(f'Unparsable line: {line}')
|
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import shlex
|
|
11
11
|
import subprocess
|
|
12
12
|
from . import CONFIG_DIR
|
|
13
|
+
from .config import ensure_config_dir
|
|
13
14
|
from .changelist_store import alias_exists, validate_alias_name
|
|
14
15
|
from .git import get_commit_lines_since, resolve_editor
|
|
15
16
|
from .log import log
|
|
@@ -112,6 +113,7 @@ def review_command(args: argparse.Namespace) -> int:
|
|
|
112
113
|
|
|
113
114
|
# Write todo to .git-p4son/reviews/todo
|
|
114
115
|
reviews_dir = _reviews_dir(workspace_dir)
|
|
116
|
+
ensure_config_dir(workspace_dir)
|
|
115
117
|
os.makedirs(reviews_dir, exist_ok=True)
|
|
116
118
|
todo_file = _todo_path(workspace_dir)
|
|
117
119
|
with open(todo_file, 'w') as f:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local, per-user state for git-p4son.
|
|
3
|
+
|
|
4
|
+
Stored in .git-p4son/state.toml. State here reflects a single user's workspace
|
|
5
|
+
and preferences, e.g. dismissed warnings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from . import CONFIG_DIR
|
|
11
|
+
from .config import ensure_config_dir, load_toml, write_toml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def state_path(workspace_dir: str) -> str:
|
|
15
|
+
"""Return the path to the local state file."""
|
|
16
|
+
return os.path.join(workspace_dir, CONFIG_DIR, 'state.toml')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load(workspace_dir: str) -> dict:
|
|
20
|
+
return load_toml(state_path(workspace_dir))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _save(workspace_dir: str, state: dict) -> None:
|
|
24
|
+
ensure_config_dir(workspace_dir)
|
|
25
|
+
write_toml(state_path(workspace_dir), state)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_clobber_warning_dismissed(workspace_dir: str) -> bool:
|
|
29
|
+
"""Return whether the user permanently dismissed the clobber warning."""
|
|
30
|
+
return bool(_load(workspace_dir).get('clobber', {})
|
|
31
|
+
.get('dismiss_warning', False))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dismiss_clobber_warning(workspace_dir: str) -> None:
|
|
35
|
+
"""Persist that the clobber warning should never be shown again."""
|
|
36
|
+
state = _load(workspace_dir)
|
|
37
|
+
state.setdefault('clobber', {})['dismiss_warning'] = True
|
|
38
|
+
_save(workspace_dir, state)
|
|
@@ -821,6 +821,17 @@ def sync_command(args: argparse.Namespace) -> int:
|
|
|
821
821
|
return 0
|
|
822
822
|
targets = resolved_targets
|
|
823
823
|
|
|
824
|
+
# A dry run stops once the sequence is known: it syncs nothing, so it
|
|
825
|
+
# skips the clobber prompt, the workspace checks and the hooks.
|
|
826
|
+
if vars(args).get('dry_run', False):
|
|
827
|
+
log.heading('Sync sequence')
|
|
828
|
+
if resync_last_synced:
|
|
829
|
+
log.success(f'{last_sync.changelist} ({LAST_SYNCED_LABEL})')
|
|
830
|
+
else:
|
|
831
|
+
log.success(' '.join(str(cl) for cl, _ in targets))
|
|
832
|
+
log.info('Dry run, nothing synced.')
|
|
833
|
+
return 0
|
|
834
|
+
|
|
824
835
|
# Workspace line ending governs how staged git content is normalized so
|
|
825
836
|
# the post-sync merge doesn't conflict on LF-vs-CRLF differences alone.
|
|
826
837
|
# The clobber option changes whether git-ignored writable files survive
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-p4son
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Utility for keeping a Perforce workspace and local git repo in sync
|
|
5
5
|
Author-email: Andreas Andersson <andreas@neoboid.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -277,6 +277,9 @@ The depot root determines which part of the Perforce workspace git-p4son syncs.
|
|
|
277
277
|
workspace or just the directory's subtree where the git root is placed. The selection is saved in
|
|
278
278
|
`.git-p4son/config.toml` and used by all subsequent commands.
|
|
279
279
|
|
|
280
|
+
Nothing in `.git-p4son/` belongs in git. The folder has its own `.gitignore` that ignores everything in it, so there
|
|
281
|
+
is no need to list it in the workspace `.gitignore`.
|
|
282
|
+
|
|
280
283
|
The saved root uses a `$(workspace)` placeholder for the workspace (client) name, e.g.
|
|
281
284
|
`root = "//$(workspace)/Engine"`. git-p4son substitutes the live workspace name each time it runs, so renaming
|
|
282
285
|
the Perforce workspace does not break the config. You can still store a concrete name (e.g. `//my-workspace/Engine`)
|
|
@@ -295,7 +298,7 @@ saves the answer; run `git p4son writable apply` once your files are committed t
|
|
|
295
298
|
Sync local git repository with a Perforce workspace:
|
|
296
299
|
|
|
297
300
|
```sh
|
|
298
|
-
git p4son sync [changelist ...] [--force]
|
|
301
|
+
git p4son sync [changelist ...] [--force] [--dry-run]
|
|
299
302
|
```
|
|
300
303
|
|
|
301
304
|
**Arguments:**
|
|
@@ -306,6 +309,8 @@ git p4son sync [changelist ...] [--force]
|
|
|
306
309
|
|
|
307
310
|
**Options:**
|
|
308
311
|
- `-f, --force`: Allow syncing to changelists older than the current one.
|
|
312
|
+
- `-n, --dry-run`: Print the resolved sync sequence without syncing. The arguments are validated as for a
|
|
313
|
+
real sync, but nothing else runs: no clean-workspace checks, no hooks, and no prompts.
|
|
309
314
|
|
|
310
315
|
**Examples:**
|
|
311
316
|
```sh
|
|
@@ -316,6 +321,7 @@ git p4son sync 123 156 178 # sync each changelist in sequence, one commit each
|
|
|
316
321
|
git p4son sync 123 156 head # sync 123, 156, then the latest changelist
|
|
317
322
|
git p4son sync last-synced
|
|
318
323
|
git p4son sync 12345 --force
|
|
324
|
+
git p4son sync --dry-run # show the changelists a sync to latest would visit
|
|
319
325
|
```
|
|
320
326
|
|
|
321
327
|
#### pre-sync hook
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import tempfile
|
|
5
5
|
import unittest
|
|
6
6
|
|
|
7
|
+
from git_p4son import CONFIG_DIR
|
|
7
8
|
from git_p4son.changelist_store import (
|
|
8
9
|
delete_changelist_alias,
|
|
9
10
|
load_changelist_alias,
|
|
@@ -29,6 +30,12 @@ class TestAliasStoreValidatesNames(unittest.TestCase):
|
|
|
29
30
|
self.assertTrue(delete_changelist_alias('feature', self.ws))
|
|
30
31
|
self.assertIsNone(load_changelist_alias('feature', self.ws))
|
|
31
32
|
|
|
33
|
+
def test_save_ensures_config_gitignore(self):
|
|
34
|
+
save_changelist_alias('feature', '123', self.ws)
|
|
35
|
+
gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
|
|
36
|
+
with open(gitignore, encoding='utf-8') as f:
|
|
37
|
+
self.assertEqual(f.read(), '*\n')
|
|
38
|
+
|
|
32
39
|
def test_load_rejects_path_traversal(self):
|
|
33
40
|
# The store directory must exist for the relative path to resolve.
|
|
34
41
|
save_changelist_alias('some-alias', '1', self.ws)
|
|
@@ -47,6 +47,12 @@ class TestCreateParser(unittest.TestCase):
|
|
|
47
47
|
args = self.parser.parse_args(['sync', '100', '-f'])
|
|
48
48
|
self.assertTrue(args.force)
|
|
49
49
|
|
|
50
|
+
def test_sync_command_dry_run(self):
|
|
51
|
+
self.assertFalse(self.parser.parse_args(['sync']).dry_run)
|
|
52
|
+
for flag in ('--dry-run', '-n'):
|
|
53
|
+
args = self.parser.parse_args(['sync', flag])
|
|
54
|
+
self.assertTrue(args.dry_run)
|
|
55
|
+
|
|
50
56
|
def test_new_command_no_message(self):
|
|
51
57
|
args = self.parser.parse_args(['new'])
|
|
52
58
|
self.assertEqual(args.command, 'new')
|
|
@@ -122,6 +128,11 @@ class TestCreateParser(unittest.TestCase):
|
|
|
122
128
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
123
129
|
self.parser.parse_args(['update', '100', '-s', 'abc'])
|
|
124
130
|
|
|
131
|
+
def test_sequence_editor_hidden_from_help(self):
|
|
132
|
+
help_text = self.parser.format_help()
|
|
133
|
+
self.assertNotIn('_sequence-editor', help_text)
|
|
134
|
+
self.assertNotIn('SUPPRESS', help_text)
|
|
135
|
+
|
|
125
136
|
def test_version_flag(self):
|
|
126
137
|
with self.assertRaises(SystemExit) as ctx:
|
|
127
138
|
self.parser.parse_args(['--version'])
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
6
|
import tempfile
|
|
7
|
+
import time
|
|
7
8
|
import unittest
|
|
8
9
|
from unittest import mock
|
|
9
10
|
|
|
@@ -408,6 +409,24 @@ class TestRunWithOutput(unittest.TestCase):
|
|
|
408
409
|
self.assertEqual(result.stderr[-1], 'err499')
|
|
409
410
|
self.assertEqual(len(callback_lines), 5500)
|
|
410
411
|
|
|
412
|
+
def test_callback_exception_terminates_process(self):
|
|
413
|
+
"""Raising from on_output stops the command instead of waiting."""
|
|
414
|
+
code = ('import sys, time\n'
|
|
415
|
+
'print("bad", flush=True)\n'
|
|
416
|
+
'time.sleep(60)\n')
|
|
417
|
+
|
|
418
|
+
def callback(line, stream):
|
|
419
|
+
raise CommandError(f'abort on {line}')
|
|
420
|
+
|
|
421
|
+
start = time.monotonic()
|
|
422
|
+
with mock.patch('git_p4son.common.log') as mock_log:
|
|
423
|
+
with self.assertRaises(CommandError) as ctx:
|
|
424
|
+
run_with_output([sys.executable, '-c', code],
|
|
425
|
+
on_output=callback)
|
|
426
|
+
self.assertEqual(str(ctx.exception), 'abort on bad')
|
|
427
|
+
self.assertLess(time.monotonic() - start, 30)
|
|
428
|
+
mock_log.stop_spinner.assert_called_once()
|
|
429
|
+
|
|
411
430
|
|
|
412
431
|
class TestMissingExecutable(unittest.TestCase):
|
|
413
432
|
def test_run_stops_spinner_and_raises(self):
|
|
@@ -1,17 +1,59 @@
|
|
|
1
1
|
"""Tests for git_p4son.config module."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import subprocess
|
|
4
5
|
import tempfile
|
|
5
6
|
import unittest
|
|
6
7
|
|
|
7
8
|
from git_p4son import CONFIG_DIR
|
|
8
9
|
from git_p4son.config import (
|
|
9
10
|
config_path,
|
|
11
|
+
ensure_config_dir,
|
|
10
12
|
load_config,
|
|
11
13
|
save_config,
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
def _read_config_gitignore(workspace_dir):
|
|
18
|
+
with open(os.path.join(workspace_dir, CONFIG_DIR, '.gitignore'),
|
|
19
|
+
encoding='utf-8') as f:
|
|
20
|
+
return f.read()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestEnsureConfigDir(unittest.TestCase):
|
|
24
|
+
def setUp(self):
|
|
25
|
+
self._tempdir = tempfile.TemporaryDirectory()
|
|
26
|
+
self.ws = self._tempdir.name
|
|
27
|
+
|
|
28
|
+
def tearDown(self):
|
|
29
|
+
self._tempdir.cleanup()
|
|
30
|
+
|
|
31
|
+
def test_creates_dir_ignoring_everything(self):
|
|
32
|
+
ensure_config_dir(self.ws)
|
|
33
|
+
self.assertEqual(_read_config_gitignore(self.ws), '*\n')
|
|
34
|
+
|
|
35
|
+
def test_replaces_state_only_gitignore(self):
|
|
36
|
+
"""Earlier versions ignored only state.toml."""
|
|
37
|
+
os.makedirs(os.path.join(self.ws, CONFIG_DIR))
|
|
38
|
+
with open(os.path.join(self.ws, CONFIG_DIR, '.gitignore'), 'w',
|
|
39
|
+
encoding='utf-8') as f:
|
|
40
|
+
f.write('state.toml\n')
|
|
41
|
+
ensure_config_dir(self.ws)
|
|
42
|
+
self.assertEqual(_read_config_gitignore(self.ws), '*\n')
|
|
43
|
+
|
|
44
|
+
def test_save_config_ensures_gitignore(self):
|
|
45
|
+
save_config(self.ws, {'depot': {'root': '//ws/...'}})
|
|
46
|
+
self.assertEqual(_read_config_gitignore(self.ws), '*\n')
|
|
47
|
+
|
|
48
|
+
def test_git_ignores_config_dir(self):
|
|
49
|
+
subprocess.run(['git', 'init', '-q', self.ws], check=True)
|
|
50
|
+
save_config(self.ws, {'depot': {'root': '//ws/...'}})
|
|
51
|
+
status = subprocess.run(
|
|
52
|
+
['git', 'status', '--porcelain', '--untracked-files=all'],
|
|
53
|
+
cwd=self.ws, check=True, capture_output=True, text=True)
|
|
54
|
+
self.assertEqual(status.stdout, '')
|
|
55
|
+
|
|
56
|
+
|
|
15
57
|
class TestConfigPath(unittest.TestCase):
|
|
16
58
|
def test_returns_expected_path(self):
|
|
17
59
|
result = config_path('/workspace')
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for git_p4son.state module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from git_p4son import CONFIG_DIR
|
|
8
|
+
from git_p4son.state import (
|
|
9
|
+
dismiss_clobber_warning,
|
|
10
|
+
is_clobber_warning_dismissed,
|
|
11
|
+
state_path,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestClobberWarningState(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
self._tempdir = tempfile.TemporaryDirectory()
|
|
18
|
+
self.ws = self._tempdir.name
|
|
19
|
+
|
|
20
|
+
def tearDown(self):
|
|
21
|
+
self._tempdir.cleanup()
|
|
22
|
+
|
|
23
|
+
def test_default_is_not_dismissed(self):
|
|
24
|
+
self.assertFalse(is_clobber_warning_dismissed(self.ws))
|
|
25
|
+
|
|
26
|
+
def test_dismiss_persists(self):
|
|
27
|
+
dismiss_clobber_warning(self.ws)
|
|
28
|
+
self.assertTrue(is_clobber_warning_dismissed(self.ws))
|
|
29
|
+
self.assertTrue(os.path.exists(state_path(self.ws)))
|
|
30
|
+
|
|
31
|
+
def test_dismiss_ensures_config_gitignore(self):
|
|
32
|
+
dismiss_clobber_warning(self.ws)
|
|
33
|
+
gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
|
|
34
|
+
with open(gitignore, encoding='utf-8') as f:
|
|
35
|
+
self.assertEqual(f.read(), '*\n')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == '__main__':
|
|
39
|
+
unittest.main()
|
|
@@ -9,6 +9,7 @@ from unittest import mock
|
|
|
9
9
|
|
|
10
10
|
from git_p4son.common import CommandError, RunError
|
|
11
11
|
from git_p4son.perforce import (
|
|
12
|
+
P4SyncAbortError,
|
|
12
13
|
P4SyncOutputProcessor,
|
|
13
14
|
P4SyncPreviewFile,
|
|
14
15
|
get_latest_changelist,
|
|
@@ -455,6 +456,13 @@ class TestP4SyncOutputProcessor(unittest.TestCase):
|
|
|
455
456
|
processor('//...@12345 - file(s) up-to-date.', sys.stdout)
|
|
456
457
|
self.assertEqual(processor.synced_file_count, 0)
|
|
457
458
|
|
|
459
|
+
def test_failed_rename_aborts(self):
|
|
460
|
+
processor = P4SyncOutputProcessor()
|
|
461
|
+
with self.assertRaises(P4SyncAbortError):
|
|
462
|
+
processor('rename: failed to rename D:\\ws\\a.uasset after 10 '
|
|
463
|
+
'attempts: Cannot create a file when that file '
|
|
464
|
+
'already exists.', sys.stderr)
|
|
465
|
+
|
|
458
466
|
|
|
459
467
|
class TestP4Sync(unittest.TestCase):
|
|
460
468
|
@mock.patch('git_p4son.sync.run_with_output')
|
|
@@ -1655,6 +1663,82 @@ class TestSyncCommand(unittest.TestCase):
|
|
|
1655
1663
|
self.assertEqual(rc, 1)
|
|
1656
1664
|
|
|
1657
1665
|
|
|
1666
|
+
class TestSyncDryRun(unittest.TestCase):
|
|
1667
|
+
"""A dry run resolves the sync sequence, prints it and stops before
|
|
1668
|
+
anything that touches the workspaces or asks the user."""
|
|
1669
|
+
|
|
1670
|
+
def setUp(self):
|
|
1671
|
+
for target, value in (
|
|
1672
|
+
('git_p4son.depot.get_client_spec', None),
|
|
1673
|
+
('git_p4son.depot.get_depot_root', '//myclient'),
|
|
1674
|
+
('git_p4son.sync.get_latest_changelist', 10500)):
|
|
1675
|
+
patcher = mock.patch(target, return_value=value)
|
|
1676
|
+
patcher.start()
|
|
1677
|
+
self.addCleanup(patcher.stop)
|
|
1678
|
+
patcher = mock.patch('git_p4son.sync.git_last_sync',
|
|
1679
|
+
return_value=LastSync(changelist=10000,
|
|
1680
|
+
commit='abc123'))
|
|
1681
|
+
patcher.start()
|
|
1682
|
+
self.addCleanup(patcher.stop)
|
|
1683
|
+
patcher = mock.patch('git_p4son.sync.log')
|
|
1684
|
+
self.mock_log = patcher.start()
|
|
1685
|
+
self.addCleanup(patcher.stop)
|
|
1686
|
+
# Everything past the dry-run exit: none of it may run.
|
|
1687
|
+
self.untouched = {}
|
|
1688
|
+
for name in ('_handle_clobber_warning', 'sync_preflight',
|
|
1689
|
+
'run_hooks', 'get_head_commit', '_sync_pass',
|
|
1690
|
+
'commit'):
|
|
1691
|
+
patcher = mock.patch(f'git_p4son.sync.{name}')
|
|
1692
|
+
self.untouched[name] = patcher.start()
|
|
1693
|
+
self.addCleanup(patcher.stop)
|
|
1694
|
+
|
|
1695
|
+
def _run(self, changelist, force=False):
|
|
1696
|
+
args = mock.Mock(changelist=changelist, force=force,
|
|
1697
|
+
workspace_dir='/ws', dry_run=True)
|
|
1698
|
+
return sync_command(args)
|
|
1699
|
+
|
|
1700
|
+
def _sequence(self):
|
|
1701
|
+
"""The line logged under the "Sync sequence" heading."""
|
|
1702
|
+
self.mock_log.heading.assert_any_call('Sync sequence')
|
|
1703
|
+
return self.mock_log.success.call_args_list[-1].args[0]
|
|
1704
|
+
|
|
1705
|
+
def _assert_nothing_run(self):
|
|
1706
|
+
for name, patched in self.untouched.items():
|
|
1707
|
+
with self.subTest(name=name):
|
|
1708
|
+
patched.assert_not_called()
|
|
1709
|
+
|
|
1710
|
+
def test_latest(self):
|
|
1711
|
+
self.assertEqual(self._run([]), 0)
|
|
1712
|
+
self.assertEqual(self._sequence(), '10500')
|
|
1713
|
+
self._assert_nothing_run()
|
|
1714
|
+
|
|
1715
|
+
def test_sequence_ending_in_head(self):
|
|
1716
|
+
self.assertEqual(self._run(['10100', '10200', 'head']), 0)
|
|
1717
|
+
self.assertEqual(self._sequence(), '10100 10200 10500')
|
|
1718
|
+
self._assert_nothing_run()
|
|
1719
|
+
|
|
1720
|
+
def test_last_synced(self):
|
|
1721
|
+
self.assertEqual(self._run(['last-synced']), 0)
|
|
1722
|
+
self.assertEqual(self._sequence(), '10000 (last synced)')
|
|
1723
|
+
self._assert_nothing_run()
|
|
1724
|
+
|
|
1725
|
+
def test_older_changelist_with_force(self):
|
|
1726
|
+
self.assertEqual(self._run(['9000'], force=True), 0)
|
|
1727
|
+
self.assertEqual(self._sequence(), '9000')
|
|
1728
|
+
self._assert_nothing_run()
|
|
1729
|
+
|
|
1730
|
+
def test_invalid_arguments_still_fail(self):
|
|
1731
|
+
"""A dry run validates the targets exactly as a real sync does."""
|
|
1732
|
+
self.assertEqual(self._run(['9000']), 1)
|
|
1733
|
+
self.mock_log.error.assert_called_once()
|
|
1734
|
+
self._assert_nothing_run()
|
|
1735
|
+
|
|
1736
|
+
def test_already_synced(self):
|
|
1737
|
+
self.assertEqual(self._run(['10000']), 0)
|
|
1738
|
+
self.mock_log.info.assert_any_call('Already synced, nothing to do.')
|
|
1739
|
+
self._assert_nothing_run()
|
|
1740
|
+
|
|
1741
|
+
|
|
1658
1742
|
class TestMergeChangedFiles(unittest.TestCase):
|
|
1659
1743
|
"""Tests for _merge_changed_files."""
|
|
1660
1744
|
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Local, per-user state for git-p4son.
|
|
3
|
-
|
|
4
|
-
Stored in .git-p4son/state.toml, which is kept out of version control (unlike
|
|
5
|
-
config.toml, which carries shared repo setup). State here reflects a single
|
|
6
|
-
user's workspace and preferences, e.g. dismissed warnings.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import os
|
|
10
|
-
|
|
11
|
-
from . import CONFIG_DIR
|
|
12
|
-
from .config import load_toml, write_toml
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def state_path(workspace_dir: str) -> str:
|
|
16
|
-
"""Return the path to the local state file."""
|
|
17
|
-
return os.path.join(workspace_dir, CONFIG_DIR, 'state.toml')
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _load(workspace_dir: str) -> dict:
|
|
21
|
-
return load_toml(state_path(workspace_dir))
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _save(workspace_dir: str, state: dict) -> None:
|
|
25
|
-
write_toml(state_path(workspace_dir), state)
|
|
26
|
-
_ensure_gitignored(workspace_dir)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _ensure_gitignored(workspace_dir: str) -> None:
|
|
30
|
-
"""Make sure state.toml is ignored via a .gitignore inside CONFIG_DIR.
|
|
31
|
-
|
|
32
|
-
A self-contained ignore file keeps the local state out of version control
|
|
33
|
-
without touching the user's top-level .gitignore."""
|
|
34
|
-
gitignore = os.path.join(workspace_dir, CONFIG_DIR, '.gitignore')
|
|
35
|
-
entry = 'state.toml'
|
|
36
|
-
existing = []
|
|
37
|
-
if os.path.exists(gitignore):
|
|
38
|
-
with open(gitignore, encoding='utf-8') as f:
|
|
39
|
-
existing = [line.strip() for line in f]
|
|
40
|
-
if entry in existing:
|
|
41
|
-
return
|
|
42
|
-
with open(gitignore, 'a', encoding='utf-8') as f:
|
|
43
|
-
f.write(f'{entry}\n')
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def is_clobber_warning_dismissed(workspace_dir: str) -> bool:
|
|
47
|
-
"""Return whether the user permanently dismissed the clobber warning."""
|
|
48
|
-
return bool(_load(workspace_dir).get('clobber', {})
|
|
49
|
-
.get('dismiss_warning', False))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def dismiss_clobber_warning(workspace_dir: str) -> None:
|
|
53
|
-
"""Persist that the clobber warning should never be shown again."""
|
|
54
|
-
state = _load(workspace_dir)
|
|
55
|
-
state.setdefault('clobber', {})['dismiss_warning'] = True
|
|
56
|
-
_save(workspace_dir, state)
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Tests for git_p4son.state module."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import tempfile
|
|
5
|
-
import unittest
|
|
6
|
-
|
|
7
|
-
from git_p4son import CONFIG_DIR
|
|
8
|
-
from git_p4son.state import (
|
|
9
|
-
dismiss_clobber_warning,
|
|
10
|
-
is_clobber_warning_dismissed,
|
|
11
|
-
state_path,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class TestClobberWarningState(unittest.TestCase):
|
|
16
|
-
def setUp(self):
|
|
17
|
-
self._tempdir = tempfile.TemporaryDirectory()
|
|
18
|
-
self.ws = self._tempdir.name
|
|
19
|
-
|
|
20
|
-
def tearDown(self):
|
|
21
|
-
self._tempdir.cleanup()
|
|
22
|
-
|
|
23
|
-
def test_default_is_not_dismissed(self):
|
|
24
|
-
self.assertFalse(is_clobber_warning_dismissed(self.ws))
|
|
25
|
-
|
|
26
|
-
def test_dismiss_persists(self):
|
|
27
|
-
dismiss_clobber_warning(self.ws)
|
|
28
|
-
self.assertTrue(is_clobber_warning_dismissed(self.ws))
|
|
29
|
-
self.assertTrue(os.path.exists(state_path(self.ws)))
|
|
30
|
-
|
|
31
|
-
def test_dismiss_is_gitignored(self):
|
|
32
|
-
"""The state file must be kept out of version control via a
|
|
33
|
-
.gitignore inside the config dir."""
|
|
34
|
-
dismiss_clobber_warning(self.ws)
|
|
35
|
-
gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
|
|
36
|
-
self.assertTrue(os.path.exists(gitignore))
|
|
37
|
-
with open(gitignore, encoding='utf-8') as f:
|
|
38
|
-
entries = [line.strip() for line in f]
|
|
39
|
-
self.assertIn('state.toml', entries)
|
|
40
|
-
|
|
41
|
-
def test_gitignore_not_duplicated(self):
|
|
42
|
-
dismiss_clobber_warning(self.ws)
|
|
43
|
-
dismiss_clobber_warning(self.ws)
|
|
44
|
-
gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
|
|
45
|
-
with open(gitignore, encoding='utf-8') as f:
|
|
46
|
-
entries = [line.strip() for line in f if line.strip()]
|
|
47
|
-
self.assertEqual(entries.count('state.toml'), 1)
|
|
48
|
-
|
|
49
|
-
def test_dismiss_preserves_existing_gitignore_entries(self):
|
|
50
|
-
os.makedirs(os.path.join(self.ws, CONFIG_DIR))
|
|
51
|
-
gitignore = os.path.join(self.ws, CONFIG_DIR, '.gitignore')
|
|
52
|
-
with open(gitignore, 'w', encoding='utf-8') as f:
|
|
53
|
-
f.write('other.local\n')
|
|
54
|
-
|
|
55
|
-
dismiss_clobber_warning(self.ws)
|
|
56
|
-
|
|
57
|
-
with open(gitignore, encoding='utf-8') as f:
|
|
58
|
-
entries = [line.strip() for line in f if line.strip()]
|
|
59
|
-
self.assertIn('other.local', entries)
|
|
60
|
-
self.assertIn('state.toml', entries)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if __name__ == '__main__':
|
|
64
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|