gbm-local 1.1.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.
- gbm_local-1.1.0/LICENSE +21 -0
- gbm_local-1.1.0/PKG-INFO +84 -0
- gbm_local-1.1.0/README.md +71 -0
- gbm_local-1.1.0/gbm/__init__.py +2 -0
- gbm_local-1.1.0/gbm/__main__.py +3 -0
- gbm_local-1.1.0/gbm/cli.py +114 -0
- gbm_local-1.1.0/gbm/core.py +351 -0
- gbm_local-1.1.0/gbm/watcher.py +55 -0
- gbm_local-1.1.0/gbm_local.egg-info/PKG-INFO +84 -0
- gbm_local-1.1.0/gbm_local.egg-info/SOURCES.txt +17 -0
- gbm_local-1.1.0/gbm_local.egg-info/dependency_links.txt +1 -0
- gbm_local-1.1.0/gbm_local.egg-info/entry_points.txt +2 -0
- gbm_local-1.1.0/gbm_local.egg-info/top_level.txt +1 -0
- gbm_local-1.1.0/pyproject.toml +22 -0
- gbm_local-1.1.0/setup.cfg +4 -0
- gbm_local-1.1.0/tests/test_core.py +81 -0
- gbm_local-1.1.0/tests/test_minutes.py +64 -0
- gbm_local-1.1.0/tests/test_paths.py +47 -0
- gbm_local-1.1.0/tests/test_watcher.py +44 -0
gbm_local-1.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GBM contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gbm_local-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gbm-local
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Automatic local project checkpoints and recovery
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/SumanyuDeshpande/GBM
|
|
7
|
+
Project-URL: Repository, https://github.com/SumanyuDeshpande/GBM
|
|
8
|
+
Project-URL: Issues, https://github.com/SumanyuDeshpande/GBM/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# GBM — Automatic Project Checkpointing and Recovery
|
|
15
|
+
|
|
16
|
+
GBM records saved project files locally and restores earlier versions without Git commits. Python 3.10+; no runtime dependencies. This implementation uses portable filesystem polling and argparse rather than the proposed Watchdog and Typer dependencies. Windows is the tested platform; Linux/macOS code paths require validation.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
From this directory:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
python -m pip install .
|
|
24
|
+
gbm --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install the wheel from `dist`. If your Python Scripts folder is not on PATH, use `python -m gbm` in place of `gbm`. Source checkout usage also works from this directory without installation.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
Open a terminal in the project to protect:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
gbm watch # Initial checkpoint; detached background process
|
|
35
|
+
gbm status # Process liveness + recent heartbeat
|
|
36
|
+
gbm undo main.py # Previous distinct recorded file version
|
|
37
|
+
gbm undo main.py --minutes 5 # Jump to the last checkpoint at/before five minutes ago
|
|
38
|
+
gbm redo main.py # Reapply file undo; supports multiple levels
|
|
39
|
+
gbm undo # Preview affected count and confirm project undo
|
|
40
|
+
gbm undo --yes # Project undo without interactive confirmation
|
|
41
|
+
gbm stop # Capture current state, stop monitoring
|
|
42
|
+
gbm recover # Roll back an interrupted restore
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`watch` returns after the background process starts. It runs as your user, without a visible console window on Windows. It is not an operating-system service and does not restart at login. Run `watch` again after reboot or a watcher failure. Errors are written to `.gbm/watcher.log`.
|
|
46
|
+
|
|
47
|
+
Commands discover the nearest ancestor with `.gbm`; otherwise `watch` initializes the current directory. File arguments are relative to the current working directory. Checkpoints include additions and deletions, binary contents and empty files. A rename is recorded as deletion plus addition. Unsaved editor buffers are not recorded.
|
|
48
|
+
|
|
49
|
+
Example: start watching with `main.py` containing `print(1)`, save `print(2)`, wait about three seconds, then run `gbm undo main.py`. The file returns to `print(1)`; `gbm redo main.py` restores `print(2)`.
|
|
50
|
+
|
|
51
|
+
## History and recovery semantics
|
|
52
|
+
|
|
53
|
+
`.gbm/history.db` stores full content manifests and operation metadata; `.gbm/objects` stores deduplicated SHA-256 objects. History persists across terminal sessions. There is no automatic pruning or size limit; disk usage grows with unique contents.
|
|
54
|
+
|
|
55
|
+
The default scan interval is 0.5 seconds with two seconds of inactivity before a checkpoint. Files are read and hashed on each scan, so this release is intended for small to medium source trees. Rapid intermediate versions between scans are not guaranteed to be captured. Changes that never settle are saved when `stop` is called or a restore is requested.
|
|
56
|
+
|
|
57
|
+
Recovery captures pending changes first. File undo walks the distinct versions for that file; redo follows its undo stack. A new checkpointed edit to a file clears that file's redo stack. Unrelated edits retain it. Project undo walks backward through automatic project checkpoints, resets file redo stacks, and restores the whole monitored manifest. File restores do not advance the automatic project checkpoint sequence. Safety checkpoints preserve the state before every restoration and are excluded from ordinary undo navigation. There is no project redo command or general safety-checkpoint browser in this release.
|
|
58
|
+
|
|
59
|
+
Restore plans compare the current manifest immediately before application. A mismatch aborts before project writes. GBM serializes its own operations with an OS file lock, checks object hashes, writes each file with atomic replacement, verifies the final manifest, and keeps a durable pending-operation journal. A failed or interrupted restore blocks further checkpointing/restores until `gbm recover` rolls back to its pre-restore state. Recovery first preserves the currently visible contents as another safety checkpoint. A multi-file restore is not a filesystem-wide atomic transaction. External editors do not honor GBM's lock: avoid saving files during restoration; races during the write phase can still occur.
|
|
60
|
+
|
|
61
|
+
## Scope and exclusions
|
|
62
|
+
|
|
63
|
+
Edit `.gbm/config.json` while monitoring is stopped. `exclude` is a list of shell-style patterns matched against each path component. `.gbm` and `.git` are always excluded. Defaults also exclude `node_modules`, `.venv`, `venv`, `__pycache__`, `dist`, `build`, `.pytest_cache`, `.mypy_cache`, `.env`, `.env.*`, `*.pem`, `*.key`, `*.pfx`, `*.pyc`, `*.swp`, and `*.tmp`.
|
|
64
|
+
|
|
65
|
+
These patterns are not a secret scanner. Other sensitive files will be stored unless you exclude them. Objects are not encrypted. Removing an exclusion or changing scope after recording history can make older restores fail; keep the monitoring policy stable. GBM never intentionally traverses symbolic links or Windows junctions. Keep its internal storage private and do not modify it manually. Paths and history are trusted local data, not a security boundary against another process actively replacing filesystem entries.
|
|
66
|
+
|
|
67
|
+
For a conventional `.git` directory, initialization adds `/.gbm/` to `.git/info/exclude`. For Git worktrees or before creating a Git repository, add `.gbm/` to your `.gitignore` yourself.
|
|
68
|
+
|
|
69
|
+
Only file bytes and existence are versioned. Permissions, timestamps, alternate data streams, hard-link relationships and empty directories are not preserved. File/directory type collisions or locked files can prevent a restore; the journal permits recovery after resolving the obstruction. No cloud backup, project redo, GUI, encryption, history export, or production database recovery is included. GBM complements source control and backups.
|
|
70
|
+
|
|
71
|
+
## Validate and publish
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
python -m unittest discover -s tests -v
|
|
75
|
+
python -m pip wheel . --no-deps --wheel-dir dist
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tests cover file/project history, repeated undo/redo, additions/deletions, preservation of pending edits, conflicts, object corruption, interruption recovery, exclusions, deduplication, and a real detached Windows watcher lifecycle.
|
|
79
|
+
|
|
80
|
+
The source is prepared for a GitHub repository, including MIT licensing and CI. The destination repository is https://github.com/SumanyuDeshpande/GBM. The implementation has not yet been pushed or published. Review this release before publication. Treat this as an initial implementation requiring broader real-project testing before relying on it as your only recovery mechanism.
|
|
81
|
+
|
|
82
|
+
## Time-based file undo (1.1)
|
|
83
|
+
|
|
84
|
+
`gbm undo main.py --minutes 5` restores only that file from the most recent automatic checkpoint at or before five minutes before the command starts. Fractional minutes work (`--minutes 0.5` is 30 seconds). The selected checkpoint's local timestamp is displayed. If no checkpoint is old enough, GBM reports this without restoring anything; it never guesses an older version. If the file did not exist in the selected checkpoint, it is removed, with its current contents preserved for `gbm redo main.py`. Unrelated files stay unchanged. Redo reverses the entire jump in one command. Ordinary file undo after a jump continues backward from the selected checkpoint. A repeated time jump uses the current clock time, not the last undo position. Checkpoints record observed saved states, not exact historical filesystem contents between scans.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# GBM — Automatic Project Checkpointing and Recovery
|
|
2
|
+
|
|
3
|
+
GBM records saved project files locally and restores earlier versions without Git commits. Python 3.10+; no runtime dependencies. This implementation uses portable filesystem polling and argparse rather than the proposed Watchdog and Typer dependencies. Windows is the tested platform; Linux/macOS code paths require validation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
From this directory:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
python -m pip install .
|
|
11
|
+
gbm --help
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or install the wheel from `dist`. If your Python Scripts folder is not on PATH, use `python -m gbm` in place of `gbm`. Source checkout usage also works from this directory without installation.
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
Open a terminal in the project to protect:
|
|
19
|
+
|
|
20
|
+
```powershell
|
|
21
|
+
gbm watch # Initial checkpoint; detached background process
|
|
22
|
+
gbm status # Process liveness + recent heartbeat
|
|
23
|
+
gbm undo main.py # Previous distinct recorded file version
|
|
24
|
+
gbm undo main.py --minutes 5 # Jump to the last checkpoint at/before five minutes ago
|
|
25
|
+
gbm redo main.py # Reapply file undo; supports multiple levels
|
|
26
|
+
gbm undo # Preview affected count and confirm project undo
|
|
27
|
+
gbm undo --yes # Project undo without interactive confirmation
|
|
28
|
+
gbm stop # Capture current state, stop monitoring
|
|
29
|
+
gbm recover # Roll back an interrupted restore
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`watch` returns after the background process starts. It runs as your user, without a visible console window on Windows. It is not an operating-system service and does not restart at login. Run `watch` again after reboot or a watcher failure. Errors are written to `.gbm/watcher.log`.
|
|
33
|
+
|
|
34
|
+
Commands discover the nearest ancestor with `.gbm`; otherwise `watch` initializes the current directory. File arguments are relative to the current working directory. Checkpoints include additions and deletions, binary contents and empty files. A rename is recorded as deletion plus addition. Unsaved editor buffers are not recorded.
|
|
35
|
+
|
|
36
|
+
Example: start watching with `main.py` containing `print(1)`, save `print(2)`, wait about three seconds, then run `gbm undo main.py`. The file returns to `print(1)`; `gbm redo main.py` restores `print(2)`.
|
|
37
|
+
|
|
38
|
+
## History and recovery semantics
|
|
39
|
+
|
|
40
|
+
`.gbm/history.db` stores full content manifests and operation metadata; `.gbm/objects` stores deduplicated SHA-256 objects. History persists across terminal sessions. There is no automatic pruning or size limit; disk usage grows with unique contents.
|
|
41
|
+
|
|
42
|
+
The default scan interval is 0.5 seconds with two seconds of inactivity before a checkpoint. Files are read and hashed on each scan, so this release is intended for small to medium source trees. Rapid intermediate versions between scans are not guaranteed to be captured. Changes that never settle are saved when `stop` is called or a restore is requested.
|
|
43
|
+
|
|
44
|
+
Recovery captures pending changes first. File undo walks the distinct versions for that file; redo follows its undo stack. A new checkpointed edit to a file clears that file's redo stack. Unrelated edits retain it. Project undo walks backward through automatic project checkpoints, resets file redo stacks, and restores the whole monitored manifest. File restores do not advance the automatic project checkpoint sequence. Safety checkpoints preserve the state before every restoration and are excluded from ordinary undo navigation. There is no project redo command or general safety-checkpoint browser in this release.
|
|
45
|
+
|
|
46
|
+
Restore plans compare the current manifest immediately before application. A mismatch aborts before project writes. GBM serializes its own operations with an OS file lock, checks object hashes, writes each file with atomic replacement, verifies the final manifest, and keeps a durable pending-operation journal. A failed or interrupted restore blocks further checkpointing/restores until `gbm recover` rolls back to its pre-restore state. Recovery first preserves the currently visible contents as another safety checkpoint. A multi-file restore is not a filesystem-wide atomic transaction. External editors do not honor GBM's lock: avoid saving files during restoration; races during the write phase can still occur.
|
|
47
|
+
|
|
48
|
+
## Scope and exclusions
|
|
49
|
+
|
|
50
|
+
Edit `.gbm/config.json` while monitoring is stopped. `exclude` is a list of shell-style patterns matched against each path component. `.gbm` and `.git` are always excluded. Defaults also exclude `node_modules`, `.venv`, `venv`, `__pycache__`, `dist`, `build`, `.pytest_cache`, `.mypy_cache`, `.env`, `.env.*`, `*.pem`, `*.key`, `*.pfx`, `*.pyc`, `*.swp`, and `*.tmp`.
|
|
51
|
+
|
|
52
|
+
These patterns are not a secret scanner. Other sensitive files will be stored unless you exclude them. Objects are not encrypted. Removing an exclusion or changing scope after recording history can make older restores fail; keep the monitoring policy stable. GBM never intentionally traverses symbolic links or Windows junctions. Keep its internal storage private and do not modify it manually. Paths and history are trusted local data, not a security boundary against another process actively replacing filesystem entries.
|
|
53
|
+
|
|
54
|
+
For a conventional `.git` directory, initialization adds `/.gbm/` to `.git/info/exclude`. For Git worktrees or before creating a Git repository, add `.gbm/` to your `.gitignore` yourself.
|
|
55
|
+
|
|
56
|
+
Only file bytes and existence are versioned. Permissions, timestamps, alternate data streams, hard-link relationships and empty directories are not preserved. File/directory type collisions or locked files can prevent a restore; the journal permits recovery after resolving the obstruction. No cloud backup, project redo, GUI, encryption, history export, or production database recovery is included. GBM complements source control and backups.
|
|
57
|
+
|
|
58
|
+
## Validate and publish
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
python -m unittest discover -s tests -v
|
|
62
|
+
python -m pip wheel . --no-deps --wheel-dir dist
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Tests cover file/project history, repeated undo/redo, additions/deletions, preservation of pending edits, conflicts, object corruption, interruption recovery, exclusions, deduplication, and a real detached Windows watcher lifecycle.
|
|
66
|
+
|
|
67
|
+
The source is prepared for a GitHub repository, including MIT licensing and CI. The destination repository is https://github.com/SumanyuDeshpande/GBM. The implementation has not yet been pushed or published. Review this release before publication. Treat this as an initial implementation requiring broader real-project testing before relying on it as your only recovery mechanism.
|
|
68
|
+
|
|
69
|
+
## Time-based file undo (1.1)
|
|
70
|
+
|
|
71
|
+
`gbm undo main.py --minutes 5` restores only that file from the most recent automatic checkpoint at or before five minutes before the command starts. Fractional minutes work (`--minutes 0.5` is 30 seconds). The selected checkpoint's local timestamp is displayed. If no checkpoint is old enough, GBM reports this without restoring anything; it never guesses an older version. If the file did not exist in the selected checkpoint, it is removed, with its current contents preserved for `gbm redo main.py`. Unrelated files stay unchanged. Redo reverses the entire jump in one command. Ordinary file undo after a jump continues backward from the selected checkpoint. A repeated time jump uses the current clock time, not the last undo position. Checkpoints record observed saved states, not exact historical filesystem contents between scans.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from .core import Project, GBMError
|
|
10
|
+
from .watcher import active, run
|
|
11
|
+
|
|
12
|
+
def main(argv=None):
|
|
13
|
+
parser = argparse.ArgumentParser(prog='gbm', description='Automatic local project checkpoints and recovery')
|
|
14
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
15
|
+
commands.add_parser('watch', help='Start background monitoring')
|
|
16
|
+
commands.add_parser('status', help='Show watcher heartbeat and checkpoint count')
|
|
17
|
+
commands.add_parser('stop', help='Stop monitoring gracefully')
|
|
18
|
+
undo = commands.add_parser('undo', help='Restore the previous project or file version')
|
|
19
|
+
undo.add_argument('file', nargs='?')
|
|
20
|
+
undo.add_argument('--minutes', type=float, help='Restore file from the latest checkpoint at or before N minutes ago')
|
|
21
|
+
undo.add_argument('-y', '--yes', action='store_true', help='Confirm project restoration')
|
|
22
|
+
redo = commands.add_parser('redo', help='Redo a file-specific undo')
|
|
23
|
+
redo.add_argument('file')
|
|
24
|
+
recover = commands.add_parser('recover', help='Roll back an interrupted restore')
|
|
25
|
+
recover.add_argument('-y', '--yes', action='store_true')
|
|
26
|
+
internal = commands.add_parser('_watch', help=argparse.SUPPRESS)
|
|
27
|
+
internal.add_argument('root')
|
|
28
|
+
internal.add_argument('token')
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
if args.command == 'undo' and args.minutes is not None and not args.file:
|
|
31
|
+
parser.error('--minutes requires a file, for example: gbm undo main.py --minutes 5')
|
|
32
|
+
try:
|
|
33
|
+
if args.command == '_watch':
|
|
34
|
+
run(Project(args.root), args.token)
|
|
35
|
+
return 0
|
|
36
|
+
project = Project.discover()
|
|
37
|
+
if args.command != 'watch' and not (project.home / 'history.db').exists():
|
|
38
|
+
if args.command == 'status':
|
|
39
|
+
print('[GBM] Watcher: INACTIVE. No history initialized.')
|
|
40
|
+
return 0
|
|
41
|
+
raise GBMError('No GBM history exists. Run gbm watch first.')
|
|
42
|
+
if args.command == 'watch':
|
|
43
|
+
project.initialize()
|
|
44
|
+
with project.lock():
|
|
45
|
+
if active(project):
|
|
46
|
+
print('[GBM] Already watching this project.')
|
|
47
|
+
return 0
|
|
48
|
+
project.capture()
|
|
49
|
+
token = uuid.uuid4().hex
|
|
50
|
+
project.set('watcher', {'token': token, 'running': True, 'heartbeat': 0})
|
|
51
|
+
env = os.environ.copy()
|
|
52
|
+
source = str(Path(__file__).resolve().parent.parent)
|
|
53
|
+
env['PYTHONPATH'] = source + os.pathsep + env.get('PYTHONPATH', '')
|
|
54
|
+
options = {'start_new_session': True} if os.name != 'nt' else {
|
|
55
|
+
'creationflags': subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
|
|
56
|
+
with (project.home / 'watcher.log').open('ab') as log:
|
|
57
|
+
subprocess.Popen([sys.executable, '-m', 'gbm', '_watch', str(project.root), token],
|
|
58
|
+
stdin=subprocess.DEVNULL, stdout=log, stderr=log, env=env, **options)
|
|
59
|
+
for _ in range(50):
|
|
60
|
+
if active(project):
|
|
61
|
+
print(f'[GBM] Watching {project.root}. Automatic checkpointing enabled.')
|
|
62
|
+
return 0
|
|
63
|
+
time.sleep(.1)
|
|
64
|
+
raise GBMError('Watcher did not start. See .gbm/watcher.log.')
|
|
65
|
+
if args.command == 'status':
|
|
66
|
+
with project.lock():
|
|
67
|
+
with project.db() as db:
|
|
68
|
+
count, latest = db.execute('SELECT COUNT(*),MAX(created) FROM checkpoints').fetchone()
|
|
69
|
+
print(f'Project: {project.root}\nWatcher: {"ACTIVE" if active(project) else "INACTIVE"}')
|
|
70
|
+
print(f'Checkpoints: {count}\nLast checkpoint: {time.ctime(latest) if latest else "none"}')
|
|
71
|
+
if project.pending():
|
|
72
|
+
print('ATTENTION: Interrupted restoration. Run gbm recover.')
|
|
73
|
+
return 0
|
|
74
|
+
if args.command == 'stop':
|
|
75
|
+
with project.lock():
|
|
76
|
+
status = project.get('watcher', {})
|
|
77
|
+
status['running'] = False
|
|
78
|
+
project.set('watcher', status)
|
|
79
|
+
if not project.pending():
|
|
80
|
+
project.capture()
|
|
81
|
+
print('[GBM] Monitoring stopped; current files checkpointed.')
|
|
82
|
+
return 0
|
|
83
|
+
if args.command == 'recover':
|
|
84
|
+
if not args.yes and input('Roll back interrupted restoration? [y/N]: ').lower() != 'y':
|
|
85
|
+
return 0
|
|
86
|
+
with project.lock():
|
|
87
|
+
project.recover()
|
|
88
|
+
print('[GBM] Pre-restore project state recovered.')
|
|
89
|
+
return 0
|
|
90
|
+
rel = project.file_arg(args.file) if args.file else None
|
|
91
|
+
with project.lock():
|
|
92
|
+
before, target, action = project.preview(rel, args.command == 'redo', getattr(args, 'minutes', None))
|
|
93
|
+
affected = sum(before.get(p) != target.get(p) for p in set(before) | set(target))
|
|
94
|
+
if 'checkpoint' in action:
|
|
95
|
+
print(f"[GBM] Target checkpoint: #{action['checkpoint']} ({time.ctime(action['created'])})")
|
|
96
|
+
print(f'[GBM] Files affected: {affected}')
|
|
97
|
+
if affected == 0:
|
|
98
|
+
print('[GBM] File already matches the selected checkpoint. Nothing restored.')
|
|
99
|
+
return 0
|
|
100
|
+
if not rel and not args.yes and input('Restore project? [y/N]: ').lower() != 'y':
|
|
101
|
+
return 0
|
|
102
|
+
with project.lock():
|
|
103
|
+
project.apply(before, target, action)
|
|
104
|
+
print('[GBM] Restored successfully.')
|
|
105
|
+
return 0
|
|
106
|
+
except (GBMError, OSError, ValueError, KeyError) as error:
|
|
107
|
+
print(f'[GBM] {error}', file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
except (KeyboardInterrupt, EOFError):
|
|
110
|
+
print('\n[GBM] Cancelled.', file=sys.stderr)
|
|
111
|
+
return 130
|
|
112
|
+
|
|
113
|
+
if __name__ == '__main__':
|
|
114
|
+
sys.exit(main())
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Persistent history, bounded filesystem access, and serialized recovery."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import fnmatch
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {"settle_seconds": 2.0, "poll_seconds": 0.5,
|
|
14
|
+
"exclude": [".gbm", ".git", "node_modules", ".venv", "venv", "__pycache__",
|
|
15
|
+
"dist", "build", ".pytest_cache", ".mypy_cache", ".env", ".env.*",
|
|
16
|
+
"*.pem", "*.key", "*.pfx", "*.pyc", "*.swp", "*.tmp"]}
|
|
17
|
+
|
|
18
|
+
class GBMError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class Project:
|
|
22
|
+
def __init__(self, root):
|
|
23
|
+
self.root = Path(root).resolve()
|
|
24
|
+
self.home = self.root / '.gbm'
|
|
25
|
+
if self.home.is_symlink():
|
|
26
|
+
raise GBMError('.gbm must not be a symbolic link.')
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def discover(cls, start=None):
|
|
30
|
+
start = Path(start or Path.cwd()).resolve()
|
|
31
|
+
for directory in (start, *start.parents):
|
|
32
|
+
if (directory / '.gbm').is_dir():
|
|
33
|
+
return cls(directory)
|
|
34
|
+
return cls(start)
|
|
35
|
+
|
|
36
|
+
def initialize(self):
|
|
37
|
+
self.home.mkdir(exist_ok=True)
|
|
38
|
+
(self.home / 'objects').mkdir(exist_ok=True)
|
|
39
|
+
if not (self.home / 'config.json').exists():
|
|
40
|
+
self.atomic(self.home / 'config.json', json.dumps(DEFAULTS, indent=2).encode())
|
|
41
|
+
with self.db() as db:
|
|
42
|
+
db.executescript('''
|
|
43
|
+
CREATE TABLE IF NOT EXISTS checkpoints(id INTEGER PRIMARY KEY, created REAL, kind TEXT, state TEXT);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS redo(path TEXT PRIMARY KEY, stack TEXT);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS operations(id TEXT PRIMARY KEY, created REAL, status TEXT, before_state TEXT, target_state TEXT);
|
|
47
|
+
''')
|
|
48
|
+
# Git's local exclude avoids changing tracked project configuration.
|
|
49
|
+
git = self.root / '.git'
|
|
50
|
+
if git.is_dir() and not git.is_symlink():
|
|
51
|
+
exclude = git / 'info' / 'exclude'
|
|
52
|
+
exclude.parent.mkdir(exist_ok=True)
|
|
53
|
+
old = exclude.read_text() if exclude.exists() else ''
|
|
54
|
+
if '/.gbm/' not in old.splitlines():
|
|
55
|
+
exclude.write_text(old + '\n/.gbm/\n')
|
|
56
|
+
|
|
57
|
+
@contextlib.contextmanager
|
|
58
|
+
def db(self):
|
|
59
|
+
db = sqlite3.connect(self.home / 'history.db', timeout=15)
|
|
60
|
+
db.execute('PRAGMA synchronous=FULL')
|
|
61
|
+
try:
|
|
62
|
+
with db:
|
|
63
|
+
yield db
|
|
64
|
+
finally:
|
|
65
|
+
db.close()
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def lock(self):
|
|
69
|
+
with (self.home / 'lock').open('a+b') as f:
|
|
70
|
+
f.seek(0)
|
|
71
|
+
f.write(b'0')
|
|
72
|
+
f.flush()
|
|
73
|
+
f.seek(0)
|
|
74
|
+
if os.name == 'nt':
|
|
75
|
+
import msvcrt
|
|
76
|
+
deadline = time.monotonic() + 15
|
|
77
|
+
while True:
|
|
78
|
+
try:
|
|
79
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
80
|
+
break
|
|
81
|
+
except OSError:
|
|
82
|
+
if time.monotonic() > deadline:
|
|
83
|
+
raise GBMError('Project is busy; retry shortly.')
|
|
84
|
+
time.sleep(.05)
|
|
85
|
+
else:
|
|
86
|
+
import fcntl
|
|
87
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
88
|
+
try:
|
|
89
|
+
yield
|
|
90
|
+
finally:
|
|
91
|
+
f.seek(0)
|
|
92
|
+
if os.name == 'nt':
|
|
93
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
|
94
|
+
else:
|
|
95
|
+
fcntl.flock(f, fcntl.LOCK_UN)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def atomic(path, data):
|
|
99
|
+
temp = path.with_name(path.name + '.gbm-' + uuid.uuid4().hex)
|
|
100
|
+
try:
|
|
101
|
+
with temp.open('xb') as f:
|
|
102
|
+
f.write(data)
|
|
103
|
+
f.flush()
|
|
104
|
+
os.fsync(f.fileno())
|
|
105
|
+
os.replace(temp, path)
|
|
106
|
+
finally:
|
|
107
|
+
temp.unlink(missing_ok=True)
|
|
108
|
+
|
|
109
|
+
def config(self):
|
|
110
|
+
config = json.loads((self.home / 'config.json').read_text())
|
|
111
|
+
if config['poll_seconds'] <= 0 or config['settle_seconds'] < 0:
|
|
112
|
+
raise GBMError('Monitoring intervals must be positive (settle may be zero).')
|
|
113
|
+
return config
|
|
114
|
+
|
|
115
|
+
def eligible(self, rel):
|
|
116
|
+
parts = Path(rel).parts
|
|
117
|
+
return '.gbm' not in parts and '.git' not in parts and not any(
|
|
118
|
+
fnmatch.fnmatch(part, pattern) for part in parts for pattern in self.config()['exclude'])
|
|
119
|
+
|
|
120
|
+
def path(self, rel):
|
|
121
|
+
candidate = self.root / rel
|
|
122
|
+
if Path(rel).is_absolute() or '..' in Path(rel).parts or not self.eligible(rel):
|
|
123
|
+
raise GBMError('Path is outside the monitored scope.')
|
|
124
|
+
current = self.root
|
|
125
|
+
for part in Path(rel).parts:
|
|
126
|
+
current = current / part
|
|
127
|
+
if current.is_symlink() or (hasattr(current, 'is_junction') and current.is_junction()):
|
|
128
|
+
raise GBMError('Symbolic links and junctions are not monitored.')
|
|
129
|
+
if not candidate.resolve().is_relative_to(self.root):
|
|
130
|
+
raise GBMError('Path escapes the project.')
|
|
131
|
+
return candidate
|
|
132
|
+
|
|
133
|
+
def file_arg(self, value):
|
|
134
|
+
candidate = Path(value)
|
|
135
|
+
if '..' in candidate.parts:
|
|
136
|
+
raise GBMError('Path is outside the monitored scope.')
|
|
137
|
+
if not candidate.is_absolute():
|
|
138
|
+
candidate = Path.cwd() / candidate
|
|
139
|
+
# Canonicalize Windows short-name aliases just as Project.__init__ does.
|
|
140
|
+
# Check the original path first so resolution cannot hide a symlink.
|
|
141
|
+
for part in (candidate, *candidate.parents):
|
|
142
|
+
if part.resolve() == self.root:
|
|
143
|
+
break
|
|
144
|
+
if part.is_symlink() or (hasattr(part, 'is_junction') and part.is_junction()):
|
|
145
|
+
raise GBMError('Symbolic links and junctions are not monitored.')
|
|
146
|
+
try:
|
|
147
|
+
rel = candidate.resolve().relative_to(self.root).as_posix()
|
|
148
|
+
except ValueError:
|
|
149
|
+
raise GBMError('File must be inside the project.')
|
|
150
|
+
self.path(rel)
|
|
151
|
+
return rel
|
|
152
|
+
|
|
153
|
+
def scan(self):
|
|
154
|
+
state = {}
|
|
155
|
+
def scan_error(error):
|
|
156
|
+
raise error
|
|
157
|
+
for directory, dirs, files in os.walk(self.root, followlinks=False, onerror=scan_error):
|
|
158
|
+
parent = Path(directory)
|
|
159
|
+
dirs[:] = [d for d in dirs if self.eligible((parent / d).relative_to(self.root))
|
|
160
|
+
and not (parent / d).is_symlink()
|
|
161
|
+
and not (hasattr(parent / d, 'is_junction') and (parent / d).is_junction())]
|
|
162
|
+
for name in files:
|
|
163
|
+
path = parent / name
|
|
164
|
+
rel = path.relative_to(self.root).as_posix()
|
|
165
|
+
if not self.eligible(rel) or path.is_symlink():
|
|
166
|
+
continue
|
|
167
|
+
data = self.path(rel).read_bytes()
|
|
168
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
169
|
+
obj = self.home / 'objects' / digest
|
|
170
|
+
if not obj.exists():
|
|
171
|
+
self.atomic(obj, data)
|
|
172
|
+
state[rel] = digest
|
|
173
|
+
return state
|
|
174
|
+
|
|
175
|
+
def get(self, key, default=None):
|
|
176
|
+
with self.db() as db:
|
|
177
|
+
row = db.execute('SELECT value FROM meta WHERE key=?', (key,)).fetchone()
|
|
178
|
+
return json.loads(row[0]) if row else default
|
|
179
|
+
|
|
180
|
+
def set(self, key, value):
|
|
181
|
+
with self.db() as db:
|
|
182
|
+
db.execute('INSERT OR REPLACE INTO meta VALUES (?,?)', (key, json.dumps(value)))
|
|
183
|
+
|
|
184
|
+
def pending(self):
|
|
185
|
+
with self.db() as db:
|
|
186
|
+
return db.execute("SELECT id FROM operations WHERE status='pending'").fetchall()
|
|
187
|
+
|
|
188
|
+
def checkpoint(self, state, kind='automatic'):
|
|
189
|
+
with self.db() as db:
|
|
190
|
+
cursor = db.execute('INSERT INTO checkpoints(created,kind,state) VALUES (?,?,?)',
|
|
191
|
+
(time.time(), kind, json.dumps(state)))
|
|
192
|
+
number = cursor.lastrowid
|
|
193
|
+
return number
|
|
194
|
+
|
|
195
|
+
def capture(self, state=None):
|
|
196
|
+
if self.pending():
|
|
197
|
+
raise GBMError('Interrupted restore detected. Run gbm recover before continuing.')
|
|
198
|
+
state = self.scan() if state is None else state
|
|
199
|
+
baseline = self.get('baseline')
|
|
200
|
+
if baseline == state:
|
|
201
|
+
return None
|
|
202
|
+
number = self.checkpoint(state)
|
|
203
|
+
# New edits invalidate redo only for the edited files.
|
|
204
|
+
with self.db() as db:
|
|
205
|
+
for rel in set(baseline or {}) | set(state):
|
|
206
|
+
if baseline is not None and baseline.get(rel) != state.get(rel):
|
|
207
|
+
db.execute('DELETE FROM redo WHERE path=?', (rel,))
|
|
208
|
+
db.execute('DELETE FROM meta WHERE key=?', ('file_head:' + rel,))
|
|
209
|
+
self.set('baseline', state)
|
|
210
|
+
self.set('head', number)
|
|
211
|
+
return number
|
|
212
|
+
|
|
213
|
+
def preview(self, rel=None, redo=False, minutes=None):
|
|
214
|
+
if minutes is not None:
|
|
215
|
+
if redo or not rel:
|
|
216
|
+
raise GBMError('--minutes requires a file-specific undo.')
|
|
217
|
+
if not math.isfinite(minutes) or minutes <= 0:
|
|
218
|
+
raise GBMError('--minutes must be a finite number greater than zero.')
|
|
219
|
+
cutoff = time.time() - minutes * 60 if minutes is not None else None
|
|
220
|
+
if self.pending():
|
|
221
|
+
raise GBMError('Interrupted restore detected. Run gbm recover.')
|
|
222
|
+
current = self.scan()
|
|
223
|
+
self.capture(current)
|
|
224
|
+
if redo:
|
|
225
|
+
with self.db() as db:
|
|
226
|
+
row = db.execute('SELECT stack FROM redo WHERE path=?', (rel,)).fetchone()
|
|
227
|
+
stack = json.loads(row[0]) if row else []
|
|
228
|
+
if not stack:
|
|
229
|
+
raise GBMError('No eligible redo state exists for this file.')
|
|
230
|
+
target = dict(current)
|
|
231
|
+
self.assign(target, rel, stack[-1]['value'])
|
|
232
|
+
return current, target, {'redo': stack[:-1], 'rel': rel, 'file_head': stack[-1]['head']}
|
|
233
|
+
head = self.get('head')
|
|
234
|
+
if cutoff is not None:
|
|
235
|
+
with self.db() as db:
|
|
236
|
+
row = db.execute(
|
|
237
|
+
"SELECT id,created,state FROM checkpoints WHERE kind='automatic' AND created<=? "
|
|
238
|
+
"ORDER BY created DESC,id DESC LIMIT 1", (cutoff,)).fetchone()
|
|
239
|
+
if not row:
|
|
240
|
+
raise GBMError('No checkpoint exists at or before the requested time. Nothing restored.')
|
|
241
|
+
number, created, payload = row
|
|
242
|
+
target = dict(current)
|
|
243
|
+
self.assign(target, rel, json.loads(payload).get(rel))
|
|
244
|
+
return current, target, {'rel': rel, 'push': current.get(rel),
|
|
245
|
+
'file_head': number,
|
|
246
|
+
'old_head': self.get('file_head:' + rel, head),
|
|
247
|
+
'checkpoint': number, 'created': created}
|
|
248
|
+
with self.db() as db:
|
|
249
|
+
rows = db.execute("SELECT id,state FROM checkpoints WHERE id<=? AND kind='automatic' ORDER BY id DESC", (head or 0,)).fetchall()
|
|
250
|
+
if rel:
|
|
251
|
+
cursor = self.get('file_head:' + rel, head)
|
|
252
|
+
versions = []
|
|
253
|
+
for number, payload in rows:
|
|
254
|
+
if number > cursor:
|
|
255
|
+
continue
|
|
256
|
+
value = json.loads(payload).get(rel)
|
|
257
|
+
if not versions or versions[-1][1] != value:
|
|
258
|
+
versions.append((number, value))
|
|
259
|
+
if len(versions) < 2:
|
|
260
|
+
raise GBMError('No previous recoverable version exists for this file.')
|
|
261
|
+
target = dict(current)
|
|
262
|
+
self.assign(target, rel, versions[1][1])
|
|
263
|
+
return current, target, {'rel': rel, 'push': current.get(rel),
|
|
264
|
+
'file_head': versions[1][0], 'old_head': cursor}
|
|
265
|
+
if len(rows) < 2:
|
|
266
|
+
raise GBMError('No previous project checkpoint exists.')
|
|
267
|
+
return current, json.loads(rows[1][1]), {'head': rows[1][0]}
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def assign(state, rel, value):
|
|
271
|
+
if value is None:
|
|
272
|
+
state.pop(rel, None)
|
|
273
|
+
else:
|
|
274
|
+
state[rel] = value
|
|
275
|
+
|
|
276
|
+
def apply(self, before, target, action):
|
|
277
|
+
if self.scan() != before:
|
|
278
|
+
raise GBMError('Recovery conflict: project changed after preview. Nothing restored.')
|
|
279
|
+
self.checkpoint(before, 'safety')
|
|
280
|
+
# Validate every object and destination before any project mutation.
|
|
281
|
+
for rel, digest in target.items():
|
|
282
|
+
self.path(rel)
|
|
283
|
+
self.object(digest)
|
|
284
|
+
operation = uuid.uuid4().hex
|
|
285
|
+
with self.db() as db:
|
|
286
|
+
db.execute('INSERT INTO operations VALUES (?,?,?,?,?)',
|
|
287
|
+
(operation, time.time(), 'pending', json.dumps(before), json.dumps(target)))
|
|
288
|
+
self.write_state(before, target)
|
|
289
|
+
with self.db() as db:
|
|
290
|
+
if 'rel' in action:
|
|
291
|
+
rel = action['rel']
|
|
292
|
+
if 'redo' in action:
|
|
293
|
+
stack = action['redo']
|
|
294
|
+
else:
|
|
295
|
+
row = db.execute('SELECT stack FROM redo WHERE path=?', (rel,)).fetchone()
|
|
296
|
+
stack = json.loads(row[0]) if row else []
|
|
297
|
+
stack.append({'value': action['push'], 'head': action['old_head']})
|
|
298
|
+
db.execute('INSERT OR REPLACE INTO redo VALUES (?,?)', (rel, json.dumps(stack)))
|
|
299
|
+
db.execute('INSERT OR REPLACE INTO meta VALUES (?,?)', ('file_head:' + rel, json.dumps(action['file_head'])))
|
|
300
|
+
else:
|
|
301
|
+
db.execute('DELETE FROM redo')
|
|
302
|
+
db.execute("DELETE FROM meta WHERE key LIKE 'file_head:%'")
|
|
303
|
+
db.execute('INSERT OR REPLACE INTO meta VALUES (?,?)', ('baseline', json.dumps(target)))
|
|
304
|
+
if 'head' in action:
|
|
305
|
+
db.execute('INSERT OR REPLACE INTO meta VALUES (?,?)', ('head', json.dumps(action['head'])))
|
|
306
|
+
db.execute("UPDATE operations SET status='complete' WHERE id=?", (operation,))
|
|
307
|
+
|
|
308
|
+
def object(self, digest):
|
|
309
|
+
if len(digest) != 64 or any(c not in '0123456789abcdef' for c in digest):
|
|
310
|
+
raise GBMError('Invalid history object reference.')
|
|
311
|
+
data = (self.home / 'objects' / digest).read_bytes()
|
|
312
|
+
if hashlib.sha256(data).hexdigest() != digest:
|
|
313
|
+
raise GBMError('History object is corrupt; restoration stopped.')
|
|
314
|
+
return data
|
|
315
|
+
|
|
316
|
+
def write_state(self, before, target):
|
|
317
|
+
for rel in sorted(set(before) | set(target), key=lambda r: (-len(Path(r).parts), r)):
|
|
318
|
+
if before.get(rel) == target.get(rel):
|
|
319
|
+
continue
|
|
320
|
+
path = self.path(rel)
|
|
321
|
+
if rel not in target:
|
|
322
|
+
path.unlink(missing_ok=True)
|
|
323
|
+
# Only remove now-empty directories, never unmonitored contents.
|
|
324
|
+
parent = path.parent
|
|
325
|
+
while parent != self.root:
|
|
326
|
+
try:
|
|
327
|
+
parent.rmdir()
|
|
328
|
+
except OSError:
|
|
329
|
+
break
|
|
330
|
+
parent = parent.parent
|
|
331
|
+
for rel, digest in target.items():
|
|
332
|
+
if before.get(rel) != digest:
|
|
333
|
+
path = self.path(rel)
|
|
334
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
self.atomic(path, self.object(digest))
|
|
336
|
+
if self.scan() != target:
|
|
337
|
+
raise GBMError('Restore verification failed; recovery journal retained.')
|
|
338
|
+
|
|
339
|
+
def recover(self):
|
|
340
|
+
with self.db() as db:
|
|
341
|
+
rows = db.execute("SELECT id,before_state FROM operations WHERE status='pending' ORDER BY created DESC").fetchall()
|
|
342
|
+
if not rows:
|
|
343
|
+
raise GBMError('No interrupted restoration exists.')
|
|
344
|
+
current = self.scan()
|
|
345
|
+
self.checkpoint(current, 'safety')
|
|
346
|
+
target = json.loads(rows[0][1])
|
|
347
|
+
self.write_state(current, target)
|
|
348
|
+
with self.db() as db:
|
|
349
|
+
db.execute("UPDATE operations SET status='recovered' WHERE status='pending'")
|
|
350
|
+
db.execute('DELETE FROM redo')
|
|
351
|
+
self.set('baseline', target)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Detached polling watcher with a token-scoped heartbeat."""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from .core import GBMError
|
|
5
|
+
|
|
6
|
+
def active(project):
|
|
7
|
+
state = project.get('watcher', {})
|
|
8
|
+
if not (state.get('running') and time.time() - state.get('heartbeat', 0) < 5):
|
|
9
|
+
return False
|
|
10
|
+
pid = state.get('pid')
|
|
11
|
+
if not pid:
|
|
12
|
+
return False
|
|
13
|
+
if os.name == 'nt':
|
|
14
|
+
import ctypes
|
|
15
|
+
from ctypes import wintypes
|
|
16
|
+
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
17
|
+
kernel.OpenProcess.restype = wintypes.HANDLE
|
|
18
|
+
kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
19
|
+
kernel.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
20
|
+
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
21
|
+
handle = kernel.OpenProcess(0x1000, False, pid)
|
|
22
|
+
if not handle:
|
|
23
|
+
return False
|
|
24
|
+
try:
|
|
25
|
+
code = wintypes.DWORD()
|
|
26
|
+
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code)) and code.value == 259)
|
|
27
|
+
finally:
|
|
28
|
+
kernel.CloseHandle(handle)
|
|
29
|
+
try:
|
|
30
|
+
os.kill(pid, 0)
|
|
31
|
+
return True
|
|
32
|
+
except OSError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def run(project, token):
|
|
36
|
+
pending = None
|
|
37
|
+
since = time.monotonic()
|
|
38
|
+
while True:
|
|
39
|
+
with project.lock():
|
|
40
|
+
status = project.get('watcher', {})
|
|
41
|
+
if status.get('token') != token or not status.get('running'):
|
|
42
|
+
return
|
|
43
|
+
status.update(pid=os.getpid(), heartbeat=time.time())
|
|
44
|
+
project.set('watcher', status)
|
|
45
|
+
config = project.config()
|
|
46
|
+
if not project.pending():
|
|
47
|
+
state = project.scan()
|
|
48
|
+
if state == project.get('baseline'):
|
|
49
|
+
pending = None
|
|
50
|
+
elif state != pending:
|
|
51
|
+
pending, since = state, time.monotonic()
|
|
52
|
+
elif time.monotonic() - since >= config['settle_seconds']:
|
|
53
|
+
project.capture(state)
|
|
54
|
+
pending = None
|
|
55
|
+
time.sleep(config['poll_seconds'])
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gbm-local
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Automatic local project checkpoints and recovery
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/SumanyuDeshpande/GBM
|
|
7
|
+
Project-URL: Repository, https://github.com/SumanyuDeshpande/GBM
|
|
8
|
+
Project-URL: Issues, https://github.com/SumanyuDeshpande/GBM/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# GBM — Automatic Project Checkpointing and Recovery
|
|
15
|
+
|
|
16
|
+
GBM records saved project files locally and restores earlier versions without Git commits. Python 3.10+; no runtime dependencies. This implementation uses portable filesystem polling and argparse rather than the proposed Watchdog and Typer dependencies. Windows is the tested platform; Linux/macOS code paths require validation.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
From this directory:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
python -m pip install .
|
|
24
|
+
gbm --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install the wheel from `dist`. If your Python Scripts folder is not on PATH, use `python -m gbm` in place of `gbm`. Source checkout usage also works from this directory without installation.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
Open a terminal in the project to protect:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
gbm watch # Initial checkpoint; detached background process
|
|
35
|
+
gbm status # Process liveness + recent heartbeat
|
|
36
|
+
gbm undo main.py # Previous distinct recorded file version
|
|
37
|
+
gbm undo main.py --minutes 5 # Jump to the last checkpoint at/before five minutes ago
|
|
38
|
+
gbm redo main.py # Reapply file undo; supports multiple levels
|
|
39
|
+
gbm undo # Preview affected count and confirm project undo
|
|
40
|
+
gbm undo --yes # Project undo without interactive confirmation
|
|
41
|
+
gbm stop # Capture current state, stop monitoring
|
|
42
|
+
gbm recover # Roll back an interrupted restore
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`watch` returns after the background process starts. It runs as your user, without a visible console window on Windows. It is not an operating-system service and does not restart at login. Run `watch` again after reboot or a watcher failure. Errors are written to `.gbm/watcher.log`.
|
|
46
|
+
|
|
47
|
+
Commands discover the nearest ancestor with `.gbm`; otherwise `watch` initializes the current directory. File arguments are relative to the current working directory. Checkpoints include additions and deletions, binary contents and empty files. A rename is recorded as deletion plus addition. Unsaved editor buffers are not recorded.
|
|
48
|
+
|
|
49
|
+
Example: start watching with `main.py` containing `print(1)`, save `print(2)`, wait about three seconds, then run `gbm undo main.py`. The file returns to `print(1)`; `gbm redo main.py` restores `print(2)`.
|
|
50
|
+
|
|
51
|
+
## History and recovery semantics
|
|
52
|
+
|
|
53
|
+
`.gbm/history.db` stores full content manifests and operation metadata; `.gbm/objects` stores deduplicated SHA-256 objects. History persists across terminal sessions. There is no automatic pruning or size limit; disk usage grows with unique contents.
|
|
54
|
+
|
|
55
|
+
The default scan interval is 0.5 seconds with two seconds of inactivity before a checkpoint. Files are read and hashed on each scan, so this release is intended for small to medium source trees. Rapid intermediate versions between scans are not guaranteed to be captured. Changes that never settle are saved when `stop` is called or a restore is requested.
|
|
56
|
+
|
|
57
|
+
Recovery captures pending changes first. File undo walks the distinct versions for that file; redo follows its undo stack. A new checkpointed edit to a file clears that file's redo stack. Unrelated edits retain it. Project undo walks backward through automatic project checkpoints, resets file redo stacks, and restores the whole monitored manifest. File restores do not advance the automatic project checkpoint sequence. Safety checkpoints preserve the state before every restoration and are excluded from ordinary undo navigation. There is no project redo command or general safety-checkpoint browser in this release.
|
|
58
|
+
|
|
59
|
+
Restore plans compare the current manifest immediately before application. A mismatch aborts before project writes. GBM serializes its own operations with an OS file lock, checks object hashes, writes each file with atomic replacement, verifies the final manifest, and keeps a durable pending-operation journal. A failed or interrupted restore blocks further checkpointing/restores until `gbm recover` rolls back to its pre-restore state. Recovery first preserves the currently visible contents as another safety checkpoint. A multi-file restore is not a filesystem-wide atomic transaction. External editors do not honor GBM's lock: avoid saving files during restoration; races during the write phase can still occur.
|
|
60
|
+
|
|
61
|
+
## Scope and exclusions
|
|
62
|
+
|
|
63
|
+
Edit `.gbm/config.json` while monitoring is stopped. `exclude` is a list of shell-style patterns matched against each path component. `.gbm` and `.git` are always excluded. Defaults also exclude `node_modules`, `.venv`, `venv`, `__pycache__`, `dist`, `build`, `.pytest_cache`, `.mypy_cache`, `.env`, `.env.*`, `*.pem`, `*.key`, `*.pfx`, `*.pyc`, `*.swp`, and `*.tmp`.
|
|
64
|
+
|
|
65
|
+
These patterns are not a secret scanner. Other sensitive files will be stored unless you exclude them. Objects are not encrypted. Removing an exclusion or changing scope after recording history can make older restores fail; keep the monitoring policy stable. GBM never intentionally traverses symbolic links or Windows junctions. Keep its internal storage private and do not modify it manually. Paths and history are trusted local data, not a security boundary against another process actively replacing filesystem entries.
|
|
66
|
+
|
|
67
|
+
For a conventional `.git` directory, initialization adds `/.gbm/` to `.git/info/exclude`. For Git worktrees or before creating a Git repository, add `.gbm/` to your `.gitignore` yourself.
|
|
68
|
+
|
|
69
|
+
Only file bytes and existence are versioned. Permissions, timestamps, alternate data streams, hard-link relationships and empty directories are not preserved. File/directory type collisions or locked files can prevent a restore; the journal permits recovery after resolving the obstruction. No cloud backup, project redo, GUI, encryption, history export, or production database recovery is included. GBM complements source control and backups.
|
|
70
|
+
|
|
71
|
+
## Validate and publish
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
python -m unittest discover -s tests -v
|
|
75
|
+
python -m pip wheel . --no-deps --wheel-dir dist
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tests cover file/project history, repeated undo/redo, additions/deletions, preservation of pending edits, conflicts, object corruption, interruption recovery, exclusions, deduplication, and a real detached Windows watcher lifecycle.
|
|
79
|
+
|
|
80
|
+
The source is prepared for a GitHub repository, including MIT licensing and CI. The destination repository is https://github.com/SumanyuDeshpande/GBM. The implementation has not yet been pushed or published. Review this release before publication. Treat this as an initial implementation requiring broader real-project testing before relying on it as your only recovery mechanism.
|
|
81
|
+
|
|
82
|
+
## Time-based file undo (1.1)
|
|
83
|
+
|
|
84
|
+
`gbm undo main.py --minutes 5` restores only that file from the most recent automatic checkpoint at or before five minutes before the command starts. Fractional minutes work (`--minutes 0.5` is 30 seconds). The selected checkpoint's local timestamp is displayed. If no checkpoint is old enough, GBM reports this without restoring anything; it never guesses an older version. If the file did not exist in the selected checkpoint, it is removed, with its current contents preserved for `gbm redo main.py`. Unrelated files stay unchanged. Redo reverses the entire jump in one command. Ordinary file undo after a jump continues backward from the selected checkpoint. A repeated time jump uses the current clock time, not the last undo position. Checkpoints record observed saved states, not exact historical filesystem contents between scans.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
gbm/__init__.py
|
|
5
|
+
gbm/__main__.py
|
|
6
|
+
gbm/cli.py
|
|
7
|
+
gbm/core.py
|
|
8
|
+
gbm/watcher.py
|
|
9
|
+
gbm_local.egg-info/PKG-INFO
|
|
10
|
+
gbm_local.egg-info/SOURCES.txt
|
|
11
|
+
gbm_local.egg-info/dependency_links.txt
|
|
12
|
+
gbm_local.egg-info/entry_points.txt
|
|
13
|
+
gbm_local.egg-info/top_level.txt
|
|
14
|
+
tests/test_core.py
|
|
15
|
+
tests/test_minutes.py
|
|
16
|
+
tests/test_paths.py
|
|
17
|
+
tests/test_watcher.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gbm
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gbm-local"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Automatic local project checkpoints and recovery"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
gbm = "gbm.cli:main"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
include = ["gbm*"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/SumanyuDeshpande/GBM"
|
|
21
|
+
Repository = "https://github.com/SumanyuDeshpande/GBM"
|
|
22
|
+
Issues = "https://github.com/SumanyuDeshpande/GBM/issues"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from gbm.core import Project, GBMError
|
|
6
|
+
|
|
7
|
+
class HistoryTests(unittest.TestCase):
|
|
8
|
+
def setUp(self):
|
|
9
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
10
|
+
self.addCleanup(self.temp.cleanup)
|
|
11
|
+
self.root = Path(self.temp.name)
|
|
12
|
+
self.p = Project(self.root)
|
|
13
|
+
self.p.initialize()
|
|
14
|
+
def write(self, name, text):
|
|
15
|
+
(self.root / name).write_text(text)
|
|
16
|
+
def capture(self):
|
|
17
|
+
return self.p.capture()
|
|
18
|
+
def restore(self, rel=None, redo=False):
|
|
19
|
+
self.p.apply(*self.p.preview(rel, redo))
|
|
20
|
+
def test_file_undo_redo_multiple_levels(self):
|
|
21
|
+
self.write('a', 'one'); self.capture()
|
|
22
|
+
self.write('a', 'two'); self.capture()
|
|
23
|
+
self.write('a', 'three'); self.write('b','untouched'); self.capture()
|
|
24
|
+
self.restore('a'); self.assertEqual((self.root/'a').read_text(), 'two')
|
|
25
|
+
self.restore('a'); self.assertEqual((self.root/'a').read_text(), 'one')
|
|
26
|
+
self.restore('a', True); self.assertEqual((self.root/'a').read_text(), 'two')
|
|
27
|
+
self.restore('a', True); self.assertEqual((self.root/'a').read_text(), 'three')
|
|
28
|
+
self.assertEqual((self.root/'b').read_text(), 'untouched')
|
|
29
|
+
def test_project_add_delete_and_repeated_undo(self):
|
|
30
|
+
self.write('a','one'); self.capture()
|
|
31
|
+
(self.root/'a').unlink(); self.write('b','two'); self.capture()
|
|
32
|
+
self.write('b','three'); self.capture()
|
|
33
|
+
self.restore(); self.assertEqual((self.root/'b').read_text(),'two')
|
|
34
|
+
self.restore(); self.assertTrue((self.root/'a').exists()); self.assertFalse((self.root/'b').exists())
|
|
35
|
+
def test_unsaved_checkpoint_changes_preserved(self):
|
|
36
|
+
self.write('a','one'); self.capture()
|
|
37
|
+
self.write('a','two'); self.restore('a')
|
|
38
|
+
self.restore('a',True); self.assertEqual((self.root/'a').read_text(),'two')
|
|
39
|
+
def test_conflict(self):
|
|
40
|
+
self.write('a','one'); self.capture()
|
|
41
|
+
self.write('a','two'); self.capture()
|
|
42
|
+
plan=self.p.preview('a'); self.write('a','new work')
|
|
43
|
+
with self.assertRaises(GBMError): self.p.apply(*plan)
|
|
44
|
+
self.assertEqual((self.root/'a').read_text(),'new work')
|
|
45
|
+
def test_interrupt_and_recovery(self):
|
|
46
|
+
self.write('a','one'); self.capture()
|
|
47
|
+
self.write('a','two'); self.capture()
|
|
48
|
+
plan=self.p.preview('a')
|
|
49
|
+
original=self.p.write_state
|
|
50
|
+
def fail(before,target):
|
|
51
|
+
self.write('a','partial')
|
|
52
|
+
raise OSError('interrupted')
|
|
53
|
+
self.p.write_state=fail
|
|
54
|
+
with self.assertRaises(OSError): self.p.apply(*plan)
|
|
55
|
+
with self.assertRaises(GBMError): self.capture()
|
|
56
|
+
self.p.write_state=original; self.p.recover()
|
|
57
|
+
self.assertEqual((self.root/'a').read_text(),'two')
|
|
58
|
+
self.assertFalse(self.p.pending())
|
|
59
|
+
def test_exclusions_and_boundaries(self):
|
|
60
|
+
self.write('.env','secret'); self.write('a','code'); self.capture()
|
|
61
|
+
self.assertEqual(set(self.p.scan()), {'a'})
|
|
62
|
+
for name in ['../outside', '.gbm/config.json', '.env']:
|
|
63
|
+
with self.assertRaises(GBMError): self.p.path(name)
|
|
64
|
+
def test_new_edit_invalidates_redo(self):
|
|
65
|
+
self.write('a','one'); self.capture()
|
|
66
|
+
self.write('a','two'); self.capture(); self.restore('a')
|
|
67
|
+
self.write('a','three'); self.capture()
|
|
68
|
+
with self.assertRaises(GBMError): self.p.preview('a',True)
|
|
69
|
+
def test_identical_state_deduplicated(self):
|
|
70
|
+
self.write('a','one'); self.capture()
|
|
71
|
+
self.assertIsNone(self.capture())
|
|
72
|
+
self.assertEqual(len(list((self.p.home/'objects').iterdir())),1)
|
|
73
|
+
def test_corrupt_object_never_overwrites(self):
|
|
74
|
+
self.write('a','one'); self.capture()
|
|
75
|
+
self.write('a','two'); self.capture()
|
|
76
|
+
plan=self.p.preview('a')
|
|
77
|
+
(self.p.home/'objects'/plan[1]['a']).write_bytes(b'broken')
|
|
78
|
+
with self.assertRaises(GBMError): self.p.apply(*plan)
|
|
79
|
+
self.assertEqual((self.root/'a').read_text(),'two')
|
|
80
|
+
|
|
81
|
+
if __name__=='__main__': unittest.main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import math
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
import unittest
|
|
6
|
+
import test_core
|
|
7
|
+
from gbm.core import GBMError
|
|
8
|
+
from gbm.cli import main
|
|
9
|
+
|
|
10
|
+
class TimedUndoTests(unittest.TestCase):
|
|
11
|
+
setUp = test_core.HistoryTests.setUp
|
|
12
|
+
write = test_core.HistoryTests.write
|
|
13
|
+
capture = test_core.HistoryTests.capture
|
|
14
|
+
restore = test_core.HistoryTests.restore
|
|
15
|
+
def timeline(self):
|
|
16
|
+
with patch('gbm.core.time.time', return_value=1000):
|
|
17
|
+
self.write('a','one'); self.write('other','original'); self.capture()
|
|
18
|
+
with patch('gbm.core.time.time', return_value=1120):
|
|
19
|
+
self.write('a','two'); self.capture()
|
|
20
|
+
with patch('gbm.core.time.time', return_value=1240):
|
|
21
|
+
self.write('a','three'); self.write('other','new'); self.capture()
|
|
22
|
+
def plan(self, minutes):
|
|
23
|
+
with patch('gbm.core.time.time', return_value=1300):
|
|
24
|
+
return self.p.preview('a', minutes=minutes)
|
|
25
|
+
def test_jump_and_single_redo(self):
|
|
26
|
+
self.timeline()
|
|
27
|
+
self.p.apply(*self.plan(3))
|
|
28
|
+
self.assertEqual((self.root/'a').read_text(),'two')
|
|
29
|
+
self.assertEqual((self.root/'other').read_text(),'new')
|
|
30
|
+
self.restore('a',True)
|
|
31
|
+
self.assertEqual((self.root/'a').read_text(),'three')
|
|
32
|
+
def test_fractional_minutes(self):
|
|
33
|
+
self.timeline(); self.p.apply(*self.plan(1.5))
|
|
34
|
+
self.assertEqual((self.root/'a').read_text(),'two')
|
|
35
|
+
def test_exact_boundary(self):
|
|
36
|
+
self.timeline(); self.p.apply(*self.plan(3))
|
|
37
|
+
self.assertEqual(self.p.get('file_head:a'),2)
|
|
38
|
+
self.restore('a'); self.assertEqual((self.root/'a').read_text(),'one')
|
|
39
|
+
def test_no_old_enough_checkpoint(self):
|
|
40
|
+
self.timeline()
|
|
41
|
+
with self.assertRaisesRegex(GBMError,'No checkpoint'):
|
|
42
|
+
self.plan(6)
|
|
43
|
+
self.assertEqual((self.root/'a').read_text(),'three')
|
|
44
|
+
def test_absent_file_and_redo(self):
|
|
45
|
+
with patch('gbm.core.time.time',return_value=1000): self.capture()
|
|
46
|
+
with patch('gbm.core.time.time',return_value=1240):
|
|
47
|
+
self.write('a','new'); self.capture()
|
|
48
|
+
self.p.apply(*self.plan(3))
|
|
49
|
+
self.assertFalse((self.root/'a').exists())
|
|
50
|
+
self.restore('a',True); self.assertEqual((self.root/'a').read_text(),'new')
|
|
51
|
+
def test_invalid_minutes(self):
|
|
52
|
+
self.timeline()
|
|
53
|
+
for minutes in [0,-1,math.nan,math.inf,-math.inf]:
|
|
54
|
+
with self.subTest(minutes=minutes), self.assertRaises(GBMError):
|
|
55
|
+
self.plan(minutes)
|
|
56
|
+
with self.assertRaises(GBMError): self.p.preview(minutes=5)
|
|
57
|
+
def test_jump_conflict(self):
|
|
58
|
+
self.timeline(); plan=self.plan(3); self.write('a','new work')
|
|
59
|
+
with self.assertRaises(GBMError): self.p.apply(*plan)
|
|
60
|
+
self.assertEqual((self.root/'a').read_text(),'new work')
|
|
61
|
+
def test_cli_requires_file(self):
|
|
62
|
+
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as caught:
|
|
63
|
+
main(['undo','--minutes','5'])
|
|
64
|
+
self.assertEqual(caught.exception.code,2)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
from gbm.core import Project, GBMError
|
|
8
|
+
|
|
9
|
+
class PathTests(unittest.TestCase):
|
|
10
|
+
def setUp(self):
|
|
11
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
12
|
+
self.addCleanup(self.temp.cleanup)
|
|
13
|
+
self.root = Path(self.temp.name) / 'project with spaces'
|
|
14
|
+
self.root.mkdir()
|
|
15
|
+
self.p = Project(self.root)
|
|
16
|
+
self.p.initialize()
|
|
17
|
+
(self.root / 'a.py').write_text('source')
|
|
18
|
+
|
|
19
|
+
@unittest.skipUnless(os.name == 'nt', 'Windows short paths')
|
|
20
|
+
def test_short_path_alias(self):
|
|
21
|
+
function = ctypes.windll.kernel32.GetShortPathNameW
|
|
22
|
+
function.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint]
|
|
23
|
+
function.restype = ctypes.c_uint
|
|
24
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
25
|
+
size = function(str(self.root), buffer, len(buffer))
|
|
26
|
+
self.assertTrue(size)
|
|
27
|
+
short = Path(buffer.value)
|
|
28
|
+
if short == self.root:
|
|
29
|
+
self.skipTest('Filesystem does not supply a distinct short-name alias')
|
|
30
|
+
# Relative and absolute arguments, including a deleted target.
|
|
31
|
+
with patch('gbm.core.Path.cwd', return_value=short):
|
|
32
|
+
self.assertEqual(self.p.file_arg('a.py'), 'a.py')
|
|
33
|
+
self.assertEqual(self.p.file_arg('deleted.py'), 'deleted.py')
|
|
34
|
+
self.assertEqual(self.p.file_arg(str(short/'a.py')), 'a.py')
|
|
35
|
+
|
|
36
|
+
def test_outside_file_rejected(self):
|
|
37
|
+
with self.assertRaises(GBMError):
|
|
38
|
+
self.p.file_arg(str(self.root.parent/'outside.py'))
|
|
39
|
+
|
|
40
|
+
def test_symlink_inside_project_rejected(self):
|
|
41
|
+
link = self.root/'linked.py'
|
|
42
|
+
try:
|
|
43
|
+
link.symlink_to(self.root/'a.py')
|
|
44
|
+
except OSError:
|
|
45
|
+
self.skipTest('Symlink creation unavailable')
|
|
46
|
+
with self.assertRaises(GBMError):
|
|
47
|
+
self.p.file_arg(str(link))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from gbm.core import Project
|
|
9
|
+
|
|
10
|
+
class WatcherTests(unittest.TestCase):
|
|
11
|
+
def test_detached_watch_checkpoint_restore_stop(self):
|
|
12
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
13
|
+
root=Path(directory)
|
|
14
|
+
env=os.environ.copy()
|
|
15
|
+
env['PYTHONPATH']=str(Path(__file__).resolve().parents[1])
|
|
16
|
+
def cli(*args):
|
|
17
|
+
result=subprocess.run([sys.executable,'-m','gbm',*args],cwd=root,env=env,
|
|
18
|
+
capture_output=True,text=True,timeout=15)
|
|
19
|
+
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
|
20
|
+
return result.stdout
|
|
21
|
+
(root/'a').write_text('one')
|
|
22
|
+
try:
|
|
23
|
+
cli('watch')
|
|
24
|
+
self.assertIn('ACTIVE',cli('status'))
|
|
25
|
+
(root/'a').write_text('two')
|
|
26
|
+
p=Project(root)
|
|
27
|
+
deadline=time.monotonic()+10
|
|
28
|
+
while time.monotonic()<deadline:
|
|
29
|
+
if p.get('baseline',{}).get('a') == __import__('hashlib').sha256(b'two').hexdigest():
|
|
30
|
+
break
|
|
31
|
+
time.sleep(.2)
|
|
32
|
+
else:
|
|
33
|
+
self.fail('Background watcher did not checkpoint the edit')
|
|
34
|
+
cli('undo','a'); self.assertEqual((root/'a').read_text(),'one')
|
|
35
|
+
with p.db() as db:
|
|
36
|
+
count=db.execute("SELECT COUNT(*) FROM checkpoints WHERE kind='automatic'").fetchone()[0]
|
|
37
|
+
time.sleep(3)
|
|
38
|
+
with p.db() as db:
|
|
39
|
+
self.assertEqual(count,db.execute("SELECT COUNT(*) FROM checkpoints WHERE kind='automatic'").fetchone()[0])
|
|
40
|
+
cli('redo','a'); self.assertEqual((root/'a').read_text(),'two')
|
|
41
|
+
cli('stop'); self.assertIn('INACTIVE',cli('status'))
|
|
42
|
+
finally:
|
|
43
|
+
cli('stop')
|
|
44
|
+
time.sleep(1)
|