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.
@@ -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.
@@ -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,2 @@
1
+ """GBM: automatic local checkpointing."""
2
+ __version__ = "1.1.0"
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from .cli import main
3
+ sys.exit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ gbm = gbm.cli:main
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)