OverviewFilesCommitsIssuesDocsReleasesPeers

hardops commits / 85a24ad

feat: update from the fork, in Blender
Repoints the updater at this fork and adds a button that applies the update, rather than only telling you one exists. The check is layered, because the two kinds of update are not the same thing: a fork update is something we can apply (git pull), while a new upstream tag still has to be bought and downloaded by hand — upstream's "updater" repo holds no addon code. So the fork is checked first and only falls through to the vendor tag check when we are in sync. Remotes are tried in order (p2p, web, forge) and the first to answer wins. punch:// is P2P over Hyperswarm and already falls back to HTTPS by itself, so a single check survives either transport being down. The check now runs on a worker thread and parks its result for a timer to apply on the main thread. The old code did a blocking requests.get during register(), which stalls Blender startup; ls-remote over punch:// would have made that far worse. Nothing off the main thread touches bpy data. Two behaviours worth stating: - Being AHEAD of the remote is not an update. A differing sha we already hold means our own commits are merely unpushed, so the check tests whether the remote tip is an object we lack (cat-file -e) rather than comparing shas. - pull is --ff-only and refuses outright when the working tree is dirty, so it can never quietly discard local fixes. Verified on 5.2.0: all three remotes resolve the same head, an unseen commit is detected as an update, a dirty tree is refused, and the operator runs clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85a24adcbbfb531f8ac51ebba4bf466bf0280625
by plankatron <fleeky@gmail.com> · 2026-08-07T21:51:55.000Z

Changed files (4) +312 -26

__init__.py +1 -0
……@@ -640,6 +640,7 @@ classes = (
640640 HOPS_OT_Timer,
641641 HOPS_OT_Bloom_Toggle,
642642 HOPS_OT_SetSmooth,
643 updater.HOPS_OT_ForkUpdate,
643644)
644645
645646from . import src
src/property/preference/__init__.py +7 -0
……@@ -45,6 +45,8 @@ class Hardops(AddonPreferences):
4545 keymap: PointerProperty(type=keymap.hops)
4646 needs_update: StringProperty()
4747 check_update: BoolProperty(name='Check Updates', default=True)
48 fork_update: BoolProperty(default=False) # a fork remote has commits we can pull
49 fork_remote: StringProperty() # which remote answered the check
4850 wrap_text: BoolProperty(default=True)
4951 operator: PointerProperty(type=operators)
5052
……@@ -63,6 +65,11 @@ class Hardops(AddonPreferences):
6365 row.alert = True
6466 row.label(text=self.needs_update)
6567
68 # a fork update is the only one we can actually apply ourselves; a
69 # vendor drop still has to be bought and downloaded by hand
70 if self.fork_update:
71 row.operator('hops.fork_update', text='Update', icon='IMPORT').remote = self.fork_remote
72
6673 row.operator("wm.url_open", text="", icon_value=get_icon_id("bmarket")).url = "https://www.blendermarket.com/account/orders"
6774 row.operator("wm.url_open", text="", icon_value=get_icon_id("artstation")).url = "https://www.artstation.com/marketplace/orders"
6875 row.operator("wm.url_open", text="", icon_value=get_icon_id("gumroad")).url = "https://gumroad.com/library"
test_updater.py +78 -0
……@@ -0,0 +1,78 @@
1import bpy, gpu, time
2
3gpu.init()
4bpy.ops.preferences.addon_enable(module='bl_ext.user_default.hardops')
5
6from bl_ext.user_default.hardops.utility import updater, addon
7
8pref = addon.preference()
9
10print("\n=== operator registered ===")
11assert hasattr(bpy.ops.hops, 'fork_update'), 'HOPS_OT_ForkUpdate not registered'
12print("hops.fork_update present")
13
14print("\n=== checkout detection ===")
15print("addon_path:", updater.addon_path())
16assert updater.is_fork_checkout(), 'addon dir is not detected as a git checkout'
17ok, head, _ = updater._git('rev-parse', 'HEAD')
18print("local HEAD:", head)
19
20print("\n=== remote_head per remote ===")
21for remote in updater.FORK_REMOTES:
22 t = time.time()
23 sha = updater.remote_head(remote)
24 print(f" {remote:6} -> {sha or '(unreachable)'} [{time.time()-t:.1f}s]")
25
26print("\n=== have_object ===")
27assert updater.have_object(head) is True
28assert updater.have_object('0' * 40) is False
29print("have_object discriminates")
30
31print("\n=== check_fork: up to date / ahead is NOT an update ===")
32remote, sha = updater.check_fork()
33print("check_fork ->", remote, sha)
34assert remote is None, f'reported an update while in sync: {remote} {sha}'
35
36print("\n=== check_fork: unseen remote commit IS an update ===")
37real_remote_head = updater.remote_head
38updater.remote_head = lambda r: 'd' * 40 if r == 'p2p' else ''
39try:
40 remote, sha = updater.check_fork()
41 print("check_fork ->", remote, sha)
42 assert remote == 'p2p' and sha == 'd' * 40, (remote, sha)
43finally:
44 updater.remote_head = real_remote_head
45print("detects an unseen commit")
46
47print("\n=== threaded check does not touch bpy from the thread ===")
48pref.check_update = True
49updater.check_for_update((26, 1, 3))
50deadline = time.time() + 180
51while updater._result is None and time.time() < deadline:
52 time.sleep(0.5)
53assert updater._result is not None, 'worker never produced a result'
54print("worker result:", updater._result)
55# timers do not fire in --background, so drain by hand as the main thread would
56assert updater._drain() is None, 'drain asked to poll again despite a result'
57print("needs_update :", repr(pref.needs_update))
58print("fork_update :", pref.fork_update)
59print("fork_remote :", repr(pref.fork_remote))
60
61print("\n=== pull refuses a dirty tree ===")
62ok, out, _ = updater._git('status', '--porcelain')
63print("dirty entries:", len(out.splitlines()))
64ok, message = updater.pull('forge')
65print("pull ->", ok, message)
66if out:
67 assert ok is False and 'Local changes' in message, message
68 print("dirty tree correctly refused")
69else:
70 assert ok, message
71 print("clean tree pulled:", message)
72
73print("\n=== operator runs end to end ===")
74result = bpy.ops.hops.fork_update(remote='forge')
75print("operator ->", result)
76assert result == {'FINISHED'}, result
77
78print("\nALL CHECKS PASSED")
utility/updater.py +226 -26
……@@ -1,43 +1,243 @@
11import re
2import pathlib
3import subprocess
4import threading
5
26import requests
37
48import bpy
59
610from .. utility import addon
711
12
13# Upstream publishes version TAGS only — that repo carries no addon code, so a new
14# tag means "a vendor drop exists, go download it", never "we can fetch it".
815update_url = 'https://api.github.com/repos/teamcsharp/HardOps-updater/tags'
916
17# This install is a git checkout of the fork. Remotes are tried in order and the
18# first that answers wins; punch:// is P2P over Hyperswarm and falls back to
19# HTTPS on its own, so it is worth trying first.
20FORK_REMOTES = ('p2p', 'web', 'forge')
21FORK_BRANCH = 'main'
22
23GIT_TIMEOUT = 20
24FORK_TIMEOUT = 120 # punch:// peer discovery is slower than HTTPS
25
1026version_regex = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+")
27
28# The check runs off the main thread, so it may not touch bpy data. It parks a
29# result here and _drain applies it from a timer, which is on the main thread.
30_result = None
31_checking = False
32
33
34def addon_path() -> pathlib.Path:
35 '''Root of the addon, which is also the fork's working tree'''
36
37 return pathlib.Path(__file__).resolve().parent.parent
38
39
40def _git(*args, timeout=GIT_TIMEOUT):
41 '''Run git against the addon directory. Returns (ok, stdout, stderr)'''
42
43 try:
44 proc = subprocess.run(
45 ('git', '-C', str(addon_path())) + args,
46 capture_output=True, text=True, timeout=timeout)
47
48 except (OSError, subprocess.SubprocessError) as error:
49 return False, '', str(error)
50
51 return proc.returncode == 0, proc.stdout.strip(), proc.stderr.strip()
52
53
54def is_fork_checkout() -> bool:
55 '''True if this install is a git working tree we can update in place'''
56
57 ok, out, _ = _git('rev-parse', '--is-inside-work-tree')
58
59 return ok and out == 'true'
60
61
62def remote_head(remote) -> str:
63 '''Branch tip on a remote without downloading anything, '' if unreachable'''
64
65 ok, out, _ = _git('ls-remote', remote, FORK_BRANCH, timeout=FORK_TIMEOUT)
66 if not ok: return ''
67
68 for line in out.splitlines():
69 parts = line.split()
70 if len(parts) == 2 and parts[1].endswith(FORK_BRANCH):
71 return parts[0]
72
73 return ''
74
75
76def have_object(sha) -> bool:
77 '''True if the commit is already in the local object store'''
78
79 ok, _, _ = _git('cat-file', '-e', f'{sha}^{{commit}}')
80
81 return ok
82
83
84def check_fork():
85 '''(remote, sha) for the first remote holding a commit we don't have.\n
86 Returns (None, '') when up to date, unreachable, or when we are AHEAD of the
87 remote — a differing sha we already hold means our own commits simply aren't
88 pushed yet, which is not an update.'''
89
90 if not is_fork_checkout(): return None, ''
91
92 for remote in FORK_REMOTES:
93 head = remote_head(remote)
94 if not head: continue
95
96 if have_object(head): return None, '' # up to date, or ahead
97
98 return remote, head
99
100 return None, ''
101
102
103def check_vendor(current_version) -> str:
104 '''Latest upstream tag if it is newer than ours, else '' '''
105
106 response = requests.get(update_url, timeout=6)
107 if response.status_code != 200:
108 raise Exception
109
110 versions = list()
111 for tag in response.json():
112 name:str = tag['name']
113
114 # pad 3 number version
115 if name.count('.') < 4: name = f'{name}.0'
116 if len(current_version) < 4: current_version = (*current_version, 0)
117
118 if version_regex.match(name):
119 versions.append(name)
120
121 if not versions: return ''
122
123 latest_version_str = sorted(versions, reverse=True)[0]
124 current_version_str = '.'.join(map(str, list(current_version)))
125
126 return latest_version_str if str(current_version_str) != str(latest_version_str) else ''
127
128
129def _worker(current_version):
130 '''Off the main thread — must not touch bpy data, only park a result'''
131
132 global _result
133
134 try:
135 remote, _sha = check_fork()
136 if remote:
137 _result = (f'Fork update available — pull from {remote}', True, remote)
138 return
139
140 except Exception:
141 pass
142
143 try:
144 latest = check_vendor(current_version)
145 _result = (f'HardOps {latest} available!', False, '') if latest else ('', False, '')
146
147 except Exception:
148 _result = ('Connection Failed', False, '')
149
150
151def _drain():
152 '''Main thread — copy the worker's result into preferences'''
153
154 global _result, _checking
155
156 if _result is None:
157 return 1.0 # not finished, poll again
158
159 message, fork_update, remote = _result
160 _result = None
161 _checking = False
162
163 try:
164 preference = addon.preference()
165 preference.needs_update = message
166 preference.fork_update = fork_update
167 preference.fork_remote = remote
168
169 if message: print(message)
170
171 except Exception:
172 pass # addon torn down while the check was in flight
173
174 return None
175
176
11177def check_for_update(current_version):
178 global _checking
179
12180 if not addon.preference().check_update: return
181 if _checking: return
13182
14183 # TODO: this should be stored in transient storage, like window_manager, so
15184 # that we don't need to clear it here
16185 addon.preference().needs_update = ""
186 addon.preference().fork_update = False
17187
18 try:
19 response = requests.get(update_url, timeout=6)
20 if response.status_code == 200:
21 resp_json = response.json()
22 versions = list()
23 for tag in resp_json:
24 name:str = tag['name']
25
26 # pad 3 number version
27 if name.count('.') < 4: name = f'{name}.0'
28 if len(current_version) < 4: current_version = (*current_version, 0)
29
30 if version_regex.match(name):
31 versions.append(name)
32 latest_version_str = sorted(versions, reverse=True)[0]
33
34 current_version_str = '.'.join(map(str, list(current_version)))
35 if str(current_version_str) != str(latest_version_str):
36 needs_update = f"HardOps {latest_version_str} available!"
37 print(needs_update)
38 addon.preference().needs_update = needs_update
39 else:
40 raise Exception
41
42 except:
43 addon.preference().needs_update = 'Connection Failed'
188 _checking = True
189
190 threading.Thread(target=_worker, args=(tuple(current_version),), daemon=True).start()
191 bpy.app.timers.register(_drain, first_interval=1.0)
192
193
194def pull(remote):
195 '''Fast-forward this install from a fork remote. Returns (ok, message)'''
196
197 if not is_fork_checkout():
198 return False, 'This install is not a git checkout of the fork'
199
200 ok, out, err = _git('status', '--porcelain')
201 if not ok:
202 return False, err or 'git status failed'
203
204 if out:
205 return False, 'Local changes present — commit or stash them first'
206
207 ok, out, err = _git('pull', '--ff-only', remote, FORK_BRANCH, timeout=FORK_TIMEOUT)
208 if not ok:
209 return False, (err or out or 'pull failed').splitlines()[-1]
210
211 if 'Already up to date' in out:
212 return True, 'Already up to date'
213
214 return True, 'Updated — restart Blender to load the new version'
215
216
217class HOPS_OT_ForkUpdate(bpy.types.Operator):
218 bl_idname = 'hops.fork_update'
219 bl_label = 'Update From Fork'
220 bl_description = 'Pull the latest commits from the fork. Blender must be restarted for them to take effect'
221
222 remote: bpy.props.StringProperty()
223
224 def execute(self, context):
225 remote = self.remote or addon.preference().fork_remote or FORK_REMOTES[0]
226
227 if context.window:
228 context.window.cursor_set('WAIT')
229
230 try:
231 ok, message = pull(remote)
232
233 finally:
234 if context.window:
235 context.window.cursor_set('DEFAULT')
236
237 self.report({'INFO'} if ok else {'WARNING'}, message)
238
239 if ok:
240 addon.preference().needs_update = message
241 addon.preference().fork_update = False
242
243 return {'FINISHED'}