bolthole 1.0.2__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,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+
18
+ - uses: actions/setup-python@v6
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+
22
+ - name: Install bats
23
+ run: sudo apt-get install -y bats
24
+
25
+ - name: Install package
26
+ run: pip install -e ".[dev]"
27
+
28
+ - name: Test
29
+ run: make test
@@ -0,0 +1,50 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v6
14
+
15
+ - name: Set up Python
16
+ uses: actions/setup-python@v6
17
+ with:
18
+ python-version: '3.12'
19
+
20
+ - name: Install bolthole
21
+ run: pip install -e ".[dev]"
22
+
23
+ - name: Install bats
24
+ run: sudo apt-get install -y bats
25
+
26
+ - name: Run tests
27
+ run: make test
28
+
29
+ publish:
30
+ runs-on: ubuntu-latest
31
+ needs: test
32
+ permissions:
33
+ id-token: write
34
+
35
+ steps:
36
+ - uses: actions/checkout@v6
37
+
38
+ - name: Set up Python
39
+ uses: actions/setup-python@v6
40
+ with:
41
+ python-version: '3.12'
42
+
43
+ - name: Install build tools
44
+ run: pip install build
45
+
46
+ - name: Build package
47
+ run: python -m build
48
+
49
+ - name: Publish to PyPI
50
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ *.egg-info/
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+
4
+ ## [v1.0.2] - 2026-01-20
5
+
6
+ Properly fix the grace period and bundle calculations.
7
+
8
+
9
+ ## [v1.0.1] - 2026-01-19
10
+
11
+ Fix grace period calculation bug.
12
+
13
+
14
+ ## [v1.0] - 2026-01-19
15
+
16
+ Automatically backup a directory to git — watch a directory for changes,
17
+ and either commit the changes or mirror the directory to another that is
18
+ a git repository, and commit changes there.
19
+
20
+ There is a grace period to allow multiple edits in a short space of time,
21
+ and multiple files changed within the grace period should be packaged as
22
+ one commit, not many.
@@ -0,0 +1,12 @@
1
+ .PHONY: test flake8 pytest bats
2
+
3
+ test: flake8 pytest bats
4
+
5
+ flake8:
6
+ flake8
7
+
8
+ pytest:
9
+ pytest
10
+
11
+ bats:
12
+ bats tests/
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: bolthole
3
+ Version: 1.0.2
4
+ Summary: bolthole
5
+ Author-email: Mark Norman Francis <norm@201created.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: watchdog
9
+ Provides-Extra: dev
10
+ Requires-Dist: flake8; extra == 'dev'
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # bolthole
15
+
16
+ Automatically backup changes to a directory to git.
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ bolthole
22
+ [--ignore GLOB [--ignore ...]] # autoignored: .git, .gitignore
23
+ [--author AUTHOR] [--message TEXT] # override your git config
24
+ [--grace SECONDS] [--bundle SECONDS] # allow for rapid edits to finish
25
+ [--remote NAME [--remote ...]] # push changes upstream
26
+ [--verbose] [--show-git] [--timeless]
27
+ [--watchdog-debug] # control verbosity
28
+ [--dry-run] # test it first
29
+ [--once] # no ongoing monitoring
30
+ source_dir # what to monitor
31
+ [dest_dir] # optionally copy to repo
32
+ ```
33
+
34
+
35
+ ## Autocommit a directory
36
+
37
+ ```bash
38
+ bolthole .
39
+ ```
40
+
41
+ Assumes the directory is already git controlled, and will refuse to start if
42
+ not. It will commit any outstanding changes. If `--once` was used, it will
43
+ then stop, otherwise it will watch for changes until killed, committing them.
44
+
45
+
46
+ ## Autocommit to a different directory
47
+
48
+ ```bash
49
+ bolthole project/ pristine-copy/
50
+ ```
51
+
52
+ If `pristine-copy` doesn't exist, it will copy it and create a git repository
53
+ there, or it will check that if directory is already git controlled and will
54
+ refuse to start if not. It will commit any outstanding changes. If `--once`
55
+ was used, it will then stop, otherwise it will watch for changes until killed,
56
+ committing them.
57
+
58
+
59
+ ## Grace and bundling
60
+
61
+ The `--grace SECONDS` option will wait until the amount of time specified
62
+ has passed before committing the file, and will restart the timer after
63
+ each change. This way, if a file is being edited repeatedly in a short
64
+ span of time, the result would be only one commit, not many.
65
+
66
+ The `--bundle SECONDS` option allows for multiple file changes to be
67
+ bundled together in one commit rather than one commit per file, as long as
68
+ they all happened at roughly the same time. The bundle time is how
69
+ recently a file needs to have been changed in order to be excluded from the
70
+ bundle.
71
+
72
+ For example, with a grace period of five minutes, and a bundle window of
73
+ one minute:
74
+
75
+ - at `t = 0m00s` — `foo.txt` is edited, and a timer for it is set for 300 seconds
76
+ - at `t = 0m30s` — `bar.txt` is edited, and a timer for it is set
77
+ for 300s (we now have `foo.txt` at 270s, `bar.txt` at 300s)
78
+ - at `t = 4m30s` — `baz.txt` is edited, and a timer for it is set for 300s
79
+ (we now have `foo.txt` at 30s, `bar.txt` at 60s, `baz.txt` at 300s)
80
+ - at `t = 5m00s` — the `foo.txt` timer goes off (we now have `foo.txt` at 0s,
81
+ `bar.txt` at 30s, `baz.txt` at 270s); the changes to `foo.txt` and `bar.txt`
82
+ were both made earlier than the bundle window, and one commit is made with
83
+ both changes
84
+ - at `t = 9m30s` — `baz.txt` is committed
85
+
86
+
87
+ ## Installation
88
+
89
+ Bolthole is installed from pypi:
90
+
91
+ ```bash
92
+ pip install bolthole
93
+ ```
@@ -0,0 +1,80 @@
1
+ # bolthole
2
+
3
+ Automatically backup changes to a directory to git.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ bolthole
9
+ [--ignore GLOB [--ignore ...]] # autoignored: .git, .gitignore
10
+ [--author AUTHOR] [--message TEXT] # override your git config
11
+ [--grace SECONDS] [--bundle SECONDS] # allow for rapid edits to finish
12
+ [--remote NAME [--remote ...]] # push changes upstream
13
+ [--verbose] [--show-git] [--timeless]
14
+ [--watchdog-debug] # control verbosity
15
+ [--dry-run] # test it first
16
+ [--once] # no ongoing monitoring
17
+ source_dir # what to monitor
18
+ [dest_dir] # optionally copy to repo
19
+ ```
20
+
21
+
22
+ ## Autocommit a directory
23
+
24
+ ```bash
25
+ bolthole .
26
+ ```
27
+
28
+ Assumes the directory is already git controlled, and will refuse to start if
29
+ not. It will commit any outstanding changes. If `--once` was used, it will
30
+ then stop, otherwise it will watch for changes until killed, committing them.
31
+
32
+
33
+ ## Autocommit to a different directory
34
+
35
+ ```bash
36
+ bolthole project/ pristine-copy/
37
+ ```
38
+
39
+ If `pristine-copy` doesn't exist, it will copy it and create a git repository
40
+ there, or it will check that if directory is already git controlled and will
41
+ refuse to start if not. It will commit any outstanding changes. If `--once`
42
+ was used, it will then stop, otherwise it will watch for changes until killed,
43
+ committing them.
44
+
45
+
46
+ ## Grace and bundling
47
+
48
+ The `--grace SECONDS` option will wait until the amount of time specified
49
+ has passed before committing the file, and will restart the timer after
50
+ each change. This way, if a file is being edited repeatedly in a short
51
+ span of time, the result would be only one commit, not many.
52
+
53
+ The `--bundle SECONDS` option allows for multiple file changes to be
54
+ bundled together in one commit rather than one commit per file, as long as
55
+ they all happened at roughly the same time. The bundle time is how
56
+ recently a file needs to have been changed in order to be excluded from the
57
+ bundle.
58
+
59
+ For example, with a grace period of five minutes, and a bundle window of
60
+ one minute:
61
+
62
+ - at `t = 0m00s` — `foo.txt` is edited, and a timer for it is set for 300 seconds
63
+ - at `t = 0m30s` — `bar.txt` is edited, and a timer for it is set
64
+ for 300s (we now have `foo.txt` at 270s, `bar.txt` at 300s)
65
+ - at `t = 4m30s` — `baz.txt` is edited, and a timer for it is set for 300s
66
+ (we now have `foo.txt` at 30s, `bar.txt` at 60s, `baz.txt` at 300s)
67
+ - at `t = 5m00s` — the `foo.txt` timer goes off (we now have `foo.txt` at 0s,
68
+ `bar.txt` at 30s, `baz.txt` at 270s); the changes to `foo.txt` and `bar.txt`
69
+ were both made earlier than the bundle window, and one commit is made with
70
+ both changes
71
+ - at `t = 9m30s` — `baz.txt` is committed
72
+
73
+
74
+ ## Installation
75
+
76
+ Bolthole is installed from pypi:
77
+
78
+ ```bash
79
+ pip install bolthole
80
+ ```
File without changes
@@ -0,0 +1,182 @@
1
+ import argparse
2
+ import sys
3
+ from importlib.metadata import version
4
+ from pathlib import Path
5
+
6
+ from bolthole.git import GitRepo, configure_output
7
+ from bolthole.watcher import watch
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(prog="bolthole")
12
+ parser.add_argument(
13
+ "--version",
14
+ action="version",
15
+ version=f"%(prog)s version v{version('bolthole')}",
16
+ )
17
+ parser.add_argument(
18
+ "--watchdog-debug",
19
+ action="store_true",
20
+ help="show raw filesystem events",
21
+ )
22
+ parser.add_argument(
23
+ "-n",
24
+ "--dry-run",
25
+ action="store_true",
26
+ help="take no actions, but report what would happen",
27
+ )
28
+ parser.add_argument(
29
+ "-v",
30
+ "--verbose",
31
+ action="store_true",
32
+ help="show file updates as well as actions taken",
33
+ )
34
+ parser.add_argument(
35
+ "--timeless",
36
+ action="store_true",
37
+ help="omit timestamps from output",
38
+ )
39
+ parser.add_argument(
40
+ "--ignore",
41
+ action="append",
42
+ default=[],
43
+ metavar="PATTERN",
44
+ help="ignore files matching pattern (repeatable)",
45
+ )
46
+ parser.add_argument(
47
+ "--show-git",
48
+ action="store_true",
49
+ help="display git commands and their output",
50
+ )
51
+ parser.add_argument(
52
+ "--once",
53
+ action="store_true",
54
+ help="commit and exit without watching for changes",
55
+ )
56
+ parser.add_argument(
57
+ "-a",
58
+ "--author",
59
+ metavar="AUTHOR",
60
+ help="override commit author (format: 'Name <email>')",
61
+ )
62
+ parser.add_argument(
63
+ "-b",
64
+ "--bundle",
65
+ type=float,
66
+ default=0,
67
+ metavar="SECONDS",
68
+ help="bundle files older than threshold into single commit",
69
+ )
70
+ parser.add_argument(
71
+ "-g",
72
+ "--grace",
73
+ type=float,
74
+ default=0,
75
+ metavar="SECONDS",
76
+ help="delay commits by grace period (default: 0)",
77
+ )
78
+ parser.add_argument(
79
+ "-m",
80
+ "--message",
81
+ metavar="MESSAGE",
82
+ help="override commit message",
83
+ )
84
+ parser.add_argument(
85
+ "-r",
86
+ "--remote",
87
+ action="append",
88
+ default=[],
89
+ metavar="REMOTE",
90
+ help="push to remote after commit (repeatable)",
91
+ )
92
+ parser.add_argument("source")
93
+ parser.add_argument("dest", nargs="?")
94
+ args = parser.parse_args()
95
+
96
+ configure_output(args.timeless)
97
+
98
+ if args.grace < 0:
99
+ print("error: grace period cannot be negative", file=sys.stderr)
100
+ sys.exit(2)
101
+
102
+ if args.bundle < 0:
103
+ print("error: bundle threshold cannot be negative", file=sys.stderr)
104
+ sys.exit(2)
105
+
106
+ if args.bundle > 0 and args.grace == 0:
107
+ print("error: bundle requires grace period", file=sys.stderr)
108
+ sys.exit(2)
109
+
110
+ if args.grace and args.bundle >= args.grace:
111
+ print("error: bundle threshold must be less than grace period", file=sys.stderr)
112
+ sys.exit(2)
113
+
114
+ source = Path(args.source).resolve()
115
+ if not source.exists():
116
+ print(
117
+ f"error: source directory does not exist: {args.source}",
118
+ file=sys.stderr,
119
+ )
120
+ sys.exit(2)
121
+
122
+ dest = None
123
+ if not args.dest:
124
+ if not GitRepo.is_repo(source):
125
+ print("error: source must be a git repository in single-directory mode",
126
+ file=sys.stderr)
127
+ sys.exit(2)
128
+ else:
129
+ dest = Path(args.dest).resolve()
130
+ if source == dest:
131
+ print("error: source and destination cannot be the same",
132
+ file=sys.stderr)
133
+ sys.exit(2)
134
+ if source.is_relative_to(dest):
135
+ print("error: source cannot be inside destination",
136
+ file=sys.stderr)
137
+ sys.exit(2)
138
+ if dest.is_relative_to(source):
139
+ print("error: destination cannot be inside source",
140
+ file=sys.stderr)
141
+ sys.exit(2)
142
+
143
+ if GitRepo.is_repo(dest):
144
+ pass
145
+ elif dest.exists() and any(dest.iterdir()):
146
+ print("error: destination exists but is not a git repository",
147
+ file=sys.stderr)
148
+ sys.exit(2)
149
+ elif not args.dry_run:
150
+ dest.mkdir(parents=True, exist_ok=True)
151
+ repo = GitRepo(dest)
152
+ repo.init()
153
+
154
+ if args.remote:
155
+ if dest:
156
+ repo_path = dest
157
+ else:
158
+ repo_path = source
159
+ repo = GitRepo(repo_path)
160
+ for remote in args.remote:
161
+ if not repo.has_remote(remote):
162
+ print(f"remote '{remote}' needs to be added",
163
+ file=sys.stderr)
164
+ sys.exit(2)
165
+
166
+ watch(
167
+ source,
168
+ dest=dest,
169
+ dry_run=args.dry_run,
170
+ verbose=args.verbose,
171
+ watchdog_debug=args.watchdog_debug,
172
+ ignore_patterns=args.ignore,
173
+ show_git=args.show_git,
174
+ source_label=args.source,
175
+ dest_label=args.dest,
176
+ once=args.once,
177
+ author=args.author,
178
+ message=args.message,
179
+ remotes=args.remote,
180
+ grace=args.grace,
181
+ bundle=args.bundle,
182
+ )
@@ -0,0 +1,116 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Event:
6
+ type: str
7
+ path: str
8
+ new_path: str | None = None
9
+
10
+
11
+ def find_origin(
12
+ renames: dict[str, str],
13
+ path: str,
14
+ ) -> str | None:
15
+ for origin, dest in renames.items():
16
+ if dest == path:
17
+ return origin
18
+ return None
19
+
20
+
21
+ def build_result(
22
+ states: dict[str, str],
23
+ renames: dict[str, str],
24
+ ) -> list[Event]:
25
+ result = []
26
+ emitted = set()
27
+ for origin, dest in sorted(renames.items()):
28
+ if origin in emitted or dest == origin:
29
+ continue
30
+ if renames.get(dest) == origin:
31
+ result.append(Event("renamed", origin, dest))
32
+ result.append(Event("renamed", dest, origin))
33
+ emitted.add(dest)
34
+ else:
35
+ result.append(Event("renamed", origin, dest))
36
+ emitted.add(origin)
37
+
38
+ for path, state in sorted(states.items()):
39
+ result.append(Event(state, path))
40
+
41
+ return result
42
+
43
+
44
+ def collapse_events(
45
+ events: list[Event],
46
+ ) -> list[Event]:
47
+ states: dict[str, str] = {}
48
+ renames: dict[str, str] = {}
49
+ vacated: set[str] = set()
50
+
51
+ for event in events:
52
+ if event.type == "created":
53
+ path = event.path
54
+ if states.get(path) == "deleted":
55
+ states[path] = "modified"
56
+ else:
57
+ states[path] = "created"
58
+ vacated.discard(path)
59
+
60
+ elif event.type == "modified":
61
+ path = event.path
62
+ if path in vacated:
63
+ continue
64
+ if find_origin(renames, path):
65
+ continue
66
+ if states.get(path) != "created":
67
+ states[path] = "modified"
68
+
69
+ elif event.type == "deleted":
70
+ path = event.path
71
+ if path in vacated:
72
+ continue
73
+ origin = find_origin(renames, path)
74
+ if origin:
75
+ del renames[origin]
76
+ states[origin] = "deleted"
77
+ continue
78
+ if states.get(path) == "created":
79
+ del states[path]
80
+ else:
81
+ states[path] = "deleted"
82
+
83
+ elif event.type == "renamed":
84
+ src, dst = event.path, event.new_path
85
+ src_origin = find_origin(renames, src)
86
+ if not src_origin:
87
+ src_origin = src
88
+ src_state = states.get(src)
89
+ dst_state = states.get(dst)
90
+
91
+ if dst_state == "deleted":
92
+ states[dst] = "modified"
93
+ if src_origin != src and src_origin in renames:
94
+ del renames[src_origin]
95
+ vacated.add(src)
96
+ continue
97
+
98
+ if dst_state == "modified":
99
+ states[src_origin] = "deleted"
100
+ vacated.add(src)
101
+ if src_origin in renames:
102
+ del renames[src_origin]
103
+ continue
104
+
105
+ if dst_state == "created":
106
+ del states[dst]
107
+
108
+ if src_state == "created":
109
+ states.pop(src, None)
110
+ states[dst] = "created"
111
+ else:
112
+ states.pop(src, None)
113
+ renames[src_origin] = dst
114
+ vacated.add(src)
115
+
116
+ return build_result(states, renames)