git-editlog 1.0.1__py3-none-any.whl

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,127 @@
1
+ import sys
2
+ import re
3
+ import time
4
+ import os
5
+ import argparse
6
+ import subprocess
7
+ import traceback
8
+ import select
9
+ import logging
10
+
11
+ from pathlib import Path
12
+
13
+ from . import repo_watcher
14
+ from . import pyinotify
15
+ from .pyinotify import WatchManager, Notifier, ProcessEvent
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ EVENTS = (
20
+ pyinotify.IN_CLOSE_WRITE | pyinotify.IN_CREATE |
21
+ pyinotify.IN_DELETE | pyinotify.IN_DELETE_SELF |
22
+ pyinotify.IN_MOVED_FROM | pyinotify.IN_MOVED_TO |
23
+ pyinotify.IN_MOVE_SELF
24
+ )
25
+
26
+ class Processor(ProcessEvent):
27
+ def my_init(self, repo_watcher):
28
+ self.watcher = repo_watcher
29
+
30
+
31
+ class RepoWatcher(repo_watcher.BaseRepoWatcher):
32
+ def process_event(self, event):
33
+ path = Path(event.pathname)
34
+ triggered = self.path_modified(path)
35
+ if triggered:
36
+
37
+ try:
38
+ relpath = path.relative_to(self.repo_path)
39
+ log.debug(f'{self.repo_path}: [{event.maskname}] {relpath}')
40
+ except ValueError:
41
+ log.debug(f'{self.repo_path}: [{event.maskname}] {path}')
42
+
43
+ def exclude_filter(self, path):
44
+ path = Path(path)
45
+
46
+ cpath = path
47
+ while cpath != self.repo_path:
48
+ if cpath == self.git_dir:
49
+ return True
50
+
51
+ if cpath.name == '.git':
52
+ return True
53
+
54
+ nextpath = cpath.parent
55
+ if nextpath == cpath:
56
+ break
57
+ cpath = nextpath
58
+
59
+ return False
60
+
61
+ def setup_watch(self, wm):
62
+ wm.add_watch(
63
+ str(self.repo_path),
64
+ EVENTS,
65
+ rec = True,
66
+ auto_add = True,
67
+ proc_fun = self.process_event,
68
+ exclude_filter = self.exclude_filter
69
+ )
70
+
71
+ wm.add_watch(
72
+ str(self.git_dir),
73
+ EVENTS,
74
+ rec = False,
75
+ auto_add = False,
76
+ proc_fun = self.process_event
77
+ )
78
+
79
+ wm.add_watch(
80
+ str(self.git_dir / 'refs'),
81
+ EVENTS,
82
+ rec = True,
83
+ auto_add = True,
84
+ proc_fun = self.process_event
85
+ )
86
+
87
+ wm.add_watch(
88
+ str(self.git_dir / 'info'),
89
+ EVENTS,
90
+ rec = True,
91
+ auto_add = True,
92
+ proc_fun = self.process_event
93
+ )
94
+
95
+ @staticmethod
96
+ def run(repo_watchers):
97
+ wm = WatchManager()
98
+ fd = wm.get_fd()
99
+
100
+ for watcher in repo_watchers:
101
+ watcher.setup_watch(wm)
102
+
103
+ notifier = Notifier(wm)
104
+
105
+ next_check_time = 0
106
+
107
+ while True:
108
+ ctime = time.time()
109
+ remain = next_check_time - ctime
110
+
111
+ ms_sleep = int(max(0, min(5, remain)) * 1000)
112
+ if notifier.check_events(ms_sleep):
113
+ notifier.read_events()
114
+ notifier.process_events()
115
+
116
+ if remain <= 0:
117
+
118
+ ctime = time.time()
119
+ next_check_time += 1.0
120
+ if next_check_time < ctime:
121
+ next_check_time = ctime + 1
122
+
123
+ for watcher in repo_watchers:
124
+ try:
125
+ watcher.check()
126
+ except Exception:
127
+ log.exception(f'ERROR checking {watcher.repo_path}')
@@ -0,0 +1,247 @@
1
+ import sys
2
+ import re
3
+ import time
4
+ import os
5
+ import argparse
6
+ import subprocess
7
+ import traceback
8
+ import logging
9
+ import copy
10
+ from pathlib import Path
11
+
12
+ from ktpanda import git_helper
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ EDITLOG_BRANCH = 'editlog'
17
+ EDITLOG_BASE_BRANCH = EDITLOG_BRANCH + '-base'
18
+
19
+ EXCLUDE_EXT = {'.3mf', '.stl', '.zip', '.jpg', '.jpeg', '.png'}
20
+
21
+ def gitdir_allow_trigger(relpath):
22
+ if relpath == 'HEAD':
23
+ return True
24
+
25
+ if relpath.startswith('refs'):
26
+ if relpath.startswith('refs/heads/editlog'):
27
+ return False
28
+ return True
29
+
30
+ if relpath.startswith('info'):
31
+ return True
32
+
33
+ return False
34
+
35
+ class BaseRepoWatcher:
36
+ def __init__(self, repo_path):
37
+ self.git = git_helper.Git(repo_path)
38
+ self.repo_path = self.git.get_toplevel()
39
+
40
+ info = self.git.run_capt(['rev-parse', '--show-toplevel', '--git-dir']).split('\n')
41
+
42
+ repo_path = self.repo_path = self.git.repo_path = Path(info[0]).absolute()
43
+ git_dir = self.git_dir = (repo_path / info[1]).absolute()
44
+
45
+ self.main_index = git_dir / 'index'
46
+ self.main_index_lock = git_dir / 'index.lock'
47
+
48
+ self.editlog_dir = git_dir / 'editlog'
49
+ self.editlog_index = self.editlog_dir / 'index'
50
+ self.editlog_head_file = self.editlog_dir / 'HEAD'
51
+
52
+ self.git.env_vars['GIT_INDEX_FILE'] = str(self.editlog_index)
53
+
54
+ self.git_with_editdir = copy.copy(self.git)
55
+ self.git_with_editdir.env_vars = self.git.env_vars | {'GIT_DIR': str(self.editlog_dir)}
56
+
57
+ self.do_not_log_if_exists = [
58
+ git_dir / 'MERGE_HEAD',
59
+ git_dir / 'rebase-apply',
60
+ git_dir / 'rebase-merge',
61
+ ]
62
+
63
+ # self.trigger_ignore = {
64
+ # str(self.git_dir),
65
+ # str(self.editlog_dir),
66
+ # str(self.main_index_lock),
67
+ # }
68
+
69
+ self.last_check_event_time = 0
70
+ self.last_event_time = 1
71
+ self.deferred_check_time = None
72
+
73
+ self.editlog_branch_head = get_branch(self.git, EDITLOG_BRANCH)
74
+ self.last_head = get_branch(self.git, EDITLOG_BASE_BRANCH)
75
+
76
+ if not self.editlog_branch_head:
77
+ # Create an empty tree to start with
78
+ empty_blob = self.git.run_capt(['hash-object', '-w', '-t', 'blob', '--stdin'], input=b'')
79
+ tree_data = f'100644 .gitkeep\0'.encode('ascii') + bytes.fromhex(empty_blob.strip())
80
+ self.editlog_tree = self.git.run_capt(['hash-object', '-w', '-t', 'tree', '--stdin'], input=tree_data)
81
+ self.editlog_branch_head = self.git.run_capt(['commit-tree', self.editlog_tree, '-m', 'Empty initial editlog commit'])
82
+ self.git.run_capt(['branch', '-f', EDITLOG_BRANCH, self.editlog_branch_head])
83
+ else:
84
+ self.editlog_tree = self.git.run_capt(['log', '-n', '1', '--format=%T', self.editlog_branch_head])
85
+
86
+ self.setup_editlog_dir()
87
+
88
+ def setup_editlog_dir(self):
89
+ dir = self.editlog_dir
90
+ dir.mkdir(exist_ok=True)
91
+
92
+ if not (f := dir / 'commondir').exists():
93
+ f.write_text('..\n')
94
+
95
+ if not (f := dir / 'HEAD').exists():
96
+ current_head = self.git.run_capt(['rev-parse', '--verify', 'HEAD'])
97
+ f.write_text(current_head + '\n')
98
+
99
+ def path_modified(self, path):
100
+ try:
101
+ # ignore all paths under gitdir except specific files
102
+ relpath = path.relative_to(self.git_dir)
103
+ if not gitdir_allow_trigger(str(relpath)):
104
+ return False
105
+ except ValueError: # path is not relative to gitdir
106
+ pass
107
+
108
+ self.last_event_time = time.time()
109
+ return True
110
+
111
+ def should_check(self):
112
+ event_time = self.last_event_time
113
+ if self.last_check_event_time == event_time:
114
+ return False
115
+
116
+ ctime = time.time()
117
+ time_since_last_event = ctime - event_time
118
+
119
+ # Wait for idle of at least 3 seconds before checking
120
+ if time_since_last_event < 3:
121
+ # If this is the first time we've seen an event since the last check, note
122
+ # the time and return False.
123
+ if self.deferred_check_time is None:
124
+ self.deferred_check_time = event_time
125
+ return False
126
+
127
+ # If it's been less than 30 seconds after the first deferred check, return False.
128
+ # Otherwise, continue and force a check.
129
+ if ctime < self.deferred_check_time + 30:
130
+ return False
131
+
132
+ self.last_check_event_time = event_time
133
+ return True
134
+
135
+ def check(self):
136
+ if self.main_index_lock.exists():
137
+ return
138
+
139
+ if not self.should_check():
140
+ return
141
+
142
+ print()
143
+ print(f'{self.repo_path}: possible changes detected, checking git...')
144
+
145
+
146
+ if any(f.exists() for f in self.do_not_log_if_exists):
147
+ print(f'{self.repo_path}: rebase/merge in progress, not logging')
148
+ return
149
+
150
+ current_head = self.git.run_capt(['rev-parse', '--verify', 'HEAD'])
151
+
152
+ # Force our head to be the head of the editlog branch so git status won't try to
153
+ # read the actual head
154
+ self.editlog_head_file.write_text(self.editlog_branch_head + '\n')
155
+
156
+ # reset our index to editlog head
157
+ self.git_with_editdir.run(['reset'])
158
+
159
+ changes = self.git_with_editdir.status(['--untracked=all', '--ignored=traditional', '--no-renames'])
160
+
161
+ add_files = []
162
+ delete_files = []
163
+ for relpath, statidx, stattree, renfrom in changes:
164
+ is_excluded = Path(relpath).suffix.lower() in EXCLUDE_EXT
165
+ if is_excluded:
166
+ # If file is excluded, but is not untracked, excluded, or staged for deletion,
167
+ # then force a deletion.
168
+ if stattree not in {'D', '?', '!'}:
169
+ delete_files.append(relpath)
170
+ else:
171
+ if stattree not in {'!'}:
172
+ add_files.append(relpath)
173
+
174
+
175
+ if add_files:
176
+ input = '\0'.join(add_files)
177
+ self.git_with_editdir.run_capt(['add', '-f', '--pathspec-from-file=-', '--pathspec-file-nul'], input=input)
178
+
179
+ if delete_files:
180
+ input = ''.join(f'0 0000000000000000000000000000000000000000\t{path}\0' for path in delete_files)
181
+ self.git_with_editdir.run_capt(['update-index', '-z', '--index-info'], input=input)
182
+
183
+ tree_hash = self.git.run_capt(['write-tree'])
184
+
185
+ if (tree_hash == self.editlog_tree and current_head == self.last_head):
186
+ print(f'{self.repo_path}: no changes')
187
+ return
188
+
189
+ ctime = time.time()
190
+ timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ctime))
191
+
192
+ commit_message = f'editlog auto commit @{timestamp}'
193
+
194
+ changed_files = []
195
+ if self.editlog_tree:
196
+ changed_files = self.git.run_capt(['diff', '-z', '--name-only', self.editlog_tree, tree_hash]).split('\0')
197
+ while changed_files and not changed_files[-1]:
198
+ changed_files.pop()
199
+
200
+ commit_subject = []
201
+ commit_body = []
202
+
203
+ if current_head != self.last_head:
204
+ commit_subject.append(f'head -> {current_head[:10]}')
205
+
206
+ if len(changed_files) > 1:
207
+ commit_subject.append(f'edit {changed_files[0]} [+{len(changed_files) - 1}]')
208
+ for fn in changed_files[1:10]:
209
+ commit_body.append(f' + {fn}')
210
+ if len(changed_files) >= 11:
211
+ commit_body.append(' + ...')
212
+ elif len(changed_files) == 1:
213
+ commit_subject.append(f'edit {changed_files[0]}')
214
+
215
+ commit_subject = ", ".join(commit_subject) + f' @{timestamp}'
216
+ print(f'{self.repo_path}: changes detected, new tree = {tree_hash}, making commit: {commit_subject}')
217
+
218
+ if commit_body:
219
+ commit_message = [commit_subject, ''] + commit_body
220
+ else:
221
+ commit_message = [commit_subject]
222
+
223
+ git_args = ['commit-tree', tree_hash, '-m', '\n'.join(commit_message)]
224
+ if self.editlog_branch_head:
225
+ git_args.append('-p')
226
+ git_args.append(self.editlog_branch_head)
227
+
228
+ if current_head != self.last_head or not self.editlog_branch_head:
229
+ git_args.append('-p')
230
+ git_args.append(current_head)
231
+
232
+ new_editlog_head = self.git.run_capt(git_args)
233
+
234
+ print(f'{self.repo_path}: created commit {new_editlog_head}')
235
+ self.git.run(['branch', '-f', EDITLOG_BRANCH, new_editlog_head])
236
+ if self.last_head != current_head:
237
+ self.git.run(['branch', '-f', EDITLOG_BASE_BRANCH, current_head])
238
+
239
+ self.editlog_branch_head = new_editlog_head
240
+ self.editlog_tree = tree_hash
241
+ self.last_head = current_head
242
+
243
+ def get_branch(git, branch_name):
244
+ try:
245
+ return git.run_capt(['rev-parse', '--verify', f'refs/heads/{branch_name}'], print_error=False)
246
+ except subprocess.CalledProcessError:
247
+ return None
@@ -0,0 +1,61 @@
1
+ import sys
2
+ import re
3
+ import time
4
+ import os
5
+ import argparse
6
+ import subprocess
7
+ import logging
8
+ import traceback
9
+ import copy
10
+ from pathlib import Path
11
+
12
+ from ktpanda import git_helper
13
+ from watchdog import events, observers
14
+
15
+ from . import repo_watcher
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ EVENTS = [
20
+ events.FileCreatedEvent, events.DirCreatedEvent,
21
+ events.FileDeletedEvent, events.DirDeletedEvent,
22
+ events.FileMovedEvent, events.DirMovedEvent,
23
+ events.FileModifiedEvent, events.DirModifiedEvent,
24
+ ]
25
+
26
+ class RepoWatcher(repo_watcher.BaseRepoWatcher, events.FileSystemEventHandler):
27
+ def on_any_event(self, event):
28
+ path = Path(event.src_path)
29
+ if self.path_modified(path):
30
+ log.debug(f'{self.repo_path}: {event}')
31
+
32
+ def setup_observer(self, observer):
33
+ observer.schedule(self, self.repo_path, recursive=True, event_filter=EVENTS)
34
+ if not self.git_dir.is_relative_to(self.repo_path):
35
+ observer.schedule(self, self.git_dir, recursive=False, event_filter=EVENTS)
36
+
37
+ @staticmethod
38
+ def run(repo_watchers):
39
+ observer = observers.Observer()
40
+ for watcher in repo_watchers:
41
+ watcher.setup_observer(observer)
42
+
43
+ observer.start()
44
+
45
+ next_check_time = 0
46
+
47
+ while True:
48
+ ctime = time.time()
49
+ remain = next_check_time - ctime
50
+ if remain > 0:
51
+ time.sleep(min(5, remain))
52
+
53
+ ctime = time.time()
54
+ next_check_time = ctime + 1
55
+
56
+
57
+ for watcher in repo_watchers:
58
+ try:
59
+ watcher.check()
60
+ except Exception:
61
+ log.exception(f'ERROR checking {watcher.repo_path}')
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-editlog
3
+ Version: 1.0.1
4
+ Summary: Auto commit edit log for Git
5
+ Home-page: https://ktpanda.org/software/git-editlog
6
+ Author: Katie Rust
7
+ Author-email: katie@ktpanda.org
8
+ License: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: ktpanda-modules>=1.0.47
16
+ Requires-Dist: watchdog>=4.0.0; sys_platform != "linux"
17
+ Dynamic: license-file
18
+
19
+ git_editlog
20
+ ===========
21
+
22
+ Watches a repository for changes, and auto-commits to a branch called 'editlog'.
@@ -0,0 +1,12 @@
1
+ git_editlog/__init__.py,sha256=bEJJgqdVW0DfyPul3EnXyFiGwFmiA6T2ZDM8X-p5dHA,158
2
+ git_editlog/__main__.py,sha256=5k3OaMoR5NRYRujPvH2LC5tC0qCzaodI_2G_WTcjtHM,1832
3
+ git_editlog/pyinotify.py,sha256=j5A8fbdNJLruflh92mvHnilRki1MTANLUWC2jyzNo_0,88060
4
+ git_editlog/pyinotify_watcher.py,sha256=Iv1928J3txA0oQ-Jx9Zvy0jyp7pNm5Y2yITz_qe1fnc,3213
5
+ git_editlog/repo_watcher.py,sha256=Cny2rRD1p_bTNs85cNnroEKRokH1F65RZVlr5UqMmG8,8914
6
+ git_editlog/watchdog_watcher.py,sha256=Bln_d-XzHGbKInZArK0QB_1t7VLyjhzV1c_XQaEq72g,1693
7
+ git_editlog-1.0.1.dist-info/licenses/LICENSE,sha256=LoQcm7r6ZGThI7UUgEFfPCsGjz3unuyDRcIFIqVFIhE,1074
8
+ git_editlog-1.0.1.dist-info/METADATA,sha256=agsVYEg5YYX2_J_-DcA0k0Wro3svx-NH3OewmfYJlBo,672
9
+ git_editlog-1.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ git_editlog-1.0.1.dist-info/entry_points.txt,sha256=Q_6mNNNHvf3j5xdAwb4gueYS3HzX5KfDGdvko2qpZtE,58
11
+ git_editlog-1.0.1.dist-info/top_level.txt,sha256=Cd5CM3pr3G26WVRXXeE-kQJ6UkobcGcSRlYcS1Po-Fg,12
12
+ git_editlog-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git_editlog = git_editlog.__main__:main
@@ -0,0 +1,20 @@
1
+ Copyright (C) 2026 Katie Rust (katie@ktpanda.org)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ git_editlog