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.
- bolthole-1.0.2/.github/workflows/ci.yml +29 -0
- bolthole-1.0.2/.github/workflows/publish.yml +50 -0
- bolthole-1.0.2/.gitignore +2 -0
- bolthole-1.0.2/CHANGELOG.md +22 -0
- bolthole-1.0.2/Makefile +12 -0
- bolthole-1.0.2/PKG-INFO +93 -0
- bolthole-1.0.2/README.md +80 -0
- bolthole-1.0.2/bolthole/__init__.py +0 -0
- bolthole-1.0.2/bolthole/cli.py +182 -0
- bolthole-1.0.2/bolthole/debounce.py +116 -0
- bolthole-1.0.2/bolthole/git.py +261 -0
- bolthole-1.0.2/bolthole/watcher.py +546 -0
- bolthole-1.0.2/pyproject.toml +46 -0
- bolthole-1.0.2/setup.cfg +3 -0
- bolthole-1.0.2/tests/cli.bats +195 -0
- bolthole-1.0.2/tests/commit_overrides.bats +103 -0
- bolthole-1.0.2/tests/debounce.bats +63 -0
- bolthole-1.0.2/tests/gitignore.bats +121 -0
- bolthole-1.0.2/tests/grace.bats +323 -0
- bolthole-1.0.2/tests/helpers.bash +122 -0
- bolthole-1.0.2/tests/ignore.bats +459 -0
- bolthole-1.0.2/tests/initial_sync.bats +255 -0
- bolthole-1.0.2/tests/mirror.bats +109 -0
- bolthole-1.0.2/tests/once.bats +69 -0
- bolthole-1.0.2/tests/output.bats +360 -0
- bolthole-1.0.2/tests/push.bats +125 -0
- bolthole-1.0.2/tests/single_dir.bats +109 -0
- bolthole-1.0.2/tests/test_commit_message.py +203 -0
- bolthole-1.0.2/tests/test_debounce.py +353 -0
- bolthole-1.0.2/tests/test_placeholder.py +2 -0
|
@@ -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,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.
|
bolthole-1.0.2/Makefile
ADDED
bolthole-1.0.2/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
bolthole-1.0.2/README.md
ADDED
|
@@ -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)
|