@@ -0,0 +1,389 @@
1+#!/usr/bin/env python3
2+"""Check the MultiGram "Hide chat list search" option (multigram/hide-search-bar/README.md) in this source tree.
3+
4+Run from anywhere:
5+
6+ python3 multigram/tools/hide_search_bar_check.py
7+ python3 multigram/tools/hide_search_bar_check.py --base forkgram # also: every line added to the hooked
8+ # upstream files since REV is marked
9+
10+What it checks:
11+ * Every hook in DialogsActivity and ForkSettingsActivity is present exactly once, is one line marked
12+ "MultiGram:", and keeps the stock token inside the original dp(...) (HideSearchBar.restHeight returns it while
13+ the option is off). Every line of those files that names HideSearchBar carries the marker.
14+ * Update guard: every other line of DialogsActivity that uses SEARCH_FIELD_HEIGHT is one of the reviewed stock
15+ lines, with the reviewed count. A new use in a Forkgram or Telegram update (or a stock line a hook replaced
16+ coming back) fails here, so it gets reviewed before it ships.
17+ * Placement: the max-scroll hook opens getMaxScrollYOffset; the tabs hook directly follows the stories overscroll
18+ in updateContextViewPosition, before the tabs are placed; the refresh hook directly follows super.onResume()
19+ (before onResume's notifyDataSetChanged); the alpha hook feeds the field's visibility and the stock search icon
20+ swap in checkUi_searchFieldVisibility, whose own factor0 line stays stock and is its only factor0; the icon hook
21+ (the "no search" variant) is the factor0 line that opens checkUi_itemSearchVisibility, the only other copy of
22+ that stock line (the stock lines of both methods are counted on code with comments stripped);
23+ the entries left stock on purpose (the icon's click listener, which the topics column's search icon calls, the
24+ Downloads item and tg://search links) still open search; the action mode hooks pair up in hideActionMode and
25+ showOrUpdateActionMode; every DialogsActivity hook but the alpha hook sits right next to a stock neighbour
26+ line (the two fling stop hooks and the page height hook also inside their stock if blocks), so moving one
27+ within its method fails; the settings row directly follows Disable Global Search and the click hook opens
28+ onClick.
29+ * HideSearchBar.java: one owned mainconfig key, written in one place and read with default false; a row id clear
30+ of Forkgram's ids; restHeight and restAlpha pass the stock value through; strings through Context.getString;
31+ FragmentSearchField is not touched; only the two hooked upstream files name the class.
32+
33+Standard library only. Exit status 0 when everything matches, 1 otherwise.
34+"""
35+import argparse
36+import os
37+import re
38+import subprocess
39+import sys
40+
41+ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
42+MAIN = "TMessagesProj/src/main/"
43+JAVA = MAIN + "java/org/telegram"
44+MG = JAVA + "/messenger/multigram/"
45+DA = JAVA + "/ui/DialogsActivity.java"
46+FORK = JAVA + "/ui/ForkSettingsActivity.java"
47+FIELD = JAVA + "/ui/Components/FragmentSearchField.java"
48+TOPICS = JAVA + "/ui/TopicsFragment.java"
49+CLS = MG + "HideSearchBar.java"
50+STRINGS = MAIN + "res/values/multigram_strings.xml"
51+SETTINGS_BACKUP = JAVA + "/messenger/forkgram/SettingsBackup.kt"
52+KEY = "multigramHideSearchBar"
53+STRING_NAMES = ["MultiGramHideSearchBar", "MultiGramHideSearchBarInfo"]
54+
55+P = r"org\.telegram\.messenger\.multigram\.HideSearchBar\."
56+M = r" // MultiGram: "
57+RH = P + r"restHeight\(DialogsActivity\.this, SEARCH_FIELD_HEIGHT\)"
58+ST = r"\(hasStories \? DialogStoriesCell\.HEIGHT_IN_DP : 0\)"
59+
60+# Hook bodies (the code of the line, without its indentation and marker), named by the spec's numbers.
61+H = {
62+ 1: r"h \+= dp\(" + RH + r"\) \* \(1f - progressToActionMode\) \* \(1f - searchAnimationProgress\) \* \(1f - rightSlidingProgress\);",
63+ 2: r"dp" + ST + r" \+ dp\(" + RH + r"\) \+ scrollYOffset,",
64+ 3: r"progressToActionMode \* \(dp" + ST + r" \+ dp\(" + RH + r"\)\)",
65+ 4: r"addH \+= dp\(" + RH + r"\);",
66+ 5: r"scrollYOffset \+ tabsYOffset \+ storiesOverscroll - dp\(4\) - dp\(SEARCH_FIELD_HEIGHT - " + RH + r"\),",
67+ 6: r"h \+= dp\(" + RH + r"\);",
68+ 7: r"t \+= dp\(" + RH + r"\);",
69+ 8: r"offset \+= dp\(" + RH + r"\);",
70+ 9: r"canScrollDy -= dp\(" + RH + r"\);",
71+ 10: r"canScrollDy \+= dp\(" + RH + r"\);",
72+ 11: r"if \(" + P + r"hides\(this\)\) return getMaxScrollYOffsetWithoutSearch\(\);",
73+ 12: r"if \(" + P + r"hides\(this\)\) totalOffset -= dp\(SEARCH_FIELD_HEIGHT\) \* \(1f - searchAnimationProgress\);",
74+ 13: r"if \(" + P + r"refresh\(this, viewPages\)\) \{ setScrollY\(Math\.max\(scrollYOffset, -getMaxScrollYOffset\(\)\)\); "
75+ r"invalidateScrollY = true; checkUi_searchFieldVisibility\(\); checkUi_menuItems\(\); \}",
76+ 14: r"translateListHeight = Math\.max\(0, dp\(" + ST + r" \+ " + RH + r"\) \+ scrollYOffset\);",
77+ 15: r"scrollAdditionalOffset = -\(dp\(" + ST + r" \+ " + RH + r"\) - finalTranslateListHeight\);",
78+ 16: r"translateListHeight = Math\.max\(0, dp\(" + ST + r" \+ " + RH + r"\) \+ scrollYOffset\);",
79+ 17: r"scrollAdditionalOffset = dp\(" + ST + r" \+ " + RH + r"\) - finalTranslateListHeight;",
80+ 18: r"final float alphaByScrollOffset = " + P + r"restAlpha\(this, 1f - MathUtils\.clamp\(\(-scrollYOffset - "
81+ r"maxScrollWithoutSearch\) / dp\(SEARCH_FIELD_HEIGHT\), 0, 1\)\);",
82+ 19: r"items\.add\(" + P + r"settingsRow\(\)\);",
83+ 20: r"if \(" + P + r"onSettingsClick\(item, view\)\) return;",
84+ 21: r"final float factor0 = isSupportSearch\(\) && !" + P + r"hides\(this\) \? 1 : 0;",
85+}
86+
87+# (file, hook number, indentation). Each hook is one line ending in a "MultiGram:" comment, present exactly once.
88+LAYOUT = [
89+ (DA, 1, 12), (DA, 2, 16), (DA, 3, 16), (DA, 4, 16), (DA, 5, 24), (DA, 6, 24), (DA, 7, 16), (DA, 8, 24),
90+ (DA, 9, 36), (DA, 10, 36), (DA, 11, 8), (DA, 12, 8), (DA, 13, 8), (DA, 14, 8), (DA, 15, 16), (DA, 16, 12),
91+ (DA, 17, 20), (DA, 18, 8), (DA, 21, 8),
92+ (FORK, 19, 8), (FORK, 20, 8),
93+]
94+HOOKS = [(rel, r"^" + " " * indent + H[n] + M, 1) for rel, n, indent in LAYOUT]
95+UPSTREAM = [DA, FORK]
96+
97+# Update guard, which also covers the replaced stock lines: every DialogsActivity line matching \bSEARCH_FIELD_HEIGHT\b
98+# and lacking the marker must be exactly one of these (stripped text), with exactly these counts.
99+REVIEWED = {
100+ "public static final int SEARCH_FIELD_HEIGHT = 48;": 1,
101+ "-dp(SEARCH_FIELD_HEIGHT + (hasStories ? DialogStoriesCell.HEIGHT_IN_DP : 0)),": 1,
102+ "childTop += dp(SEARCH_FIELD_HEIGHT);": 2,
103+ "// childTop -= dp(SEARCH_FIELD_HEIGHT);": 1,
104+ "return -lerp(dp(4), dp(SEARCH_FIELD_HEIGHT), animatorSearchVisible.getFloatValue());": 1,
105+ "int h = dp(SEARCH_FIELD_HEIGHT);": 1,
106+ "return dp(DialogStoriesCell.HEIGHT_IN_DP) + dp(SEARCH_FIELD_HEIGHT);": 1,
107+ "return dp(SEARCH_FIELD_HEIGHT);": 1,
108+ "(dp(SEARCH_FIELD_HEIGHT))": 1,
109+ "float translationY = dp(DialogStoriesCell.HEIGHT_IN_DP) + scrollYOffset + dp(SEARCH_FIELD_HEIGHT);": 1,
110+ "+ dp(DialogsActivity.SEARCH_FIELD_HEIGHT)": 1,
111+}
112+
113+errors = []
114+
115+
116+def fail(msg):
117+ errors.append(msg)
118+
119+
120+def read(rel):
121+ with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
122+ return f.read()
123+
124+
125+def strip_comments(src):
126+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
127+ return re.sub(r"//[^\n]*", "", src)
128+
129+
130+def method_body(src, signature):
131+ """Source of the method whose declaration matches signature (a regex, re.M), up to its closing brace."""
132+ m = re.search(signature, src, re.M)
133+ if not m:
134+ return None
135+ i = src.index("{", m.end() - 1)
136+ depth = 0
137+ for j in range(i, len(src)):
138+ if src[j] == "{":
139+ depth += 1
140+ elif src[j] == "}":
141+ depth -= 1
142+ if depth == 0:
143+ return src[i:j + 1]
144+ return None
145+
146+
147+def hook(n):
148+ """Hook n as it sits in a line: its code, marker and comment, up to the end of the line (no indentation)."""
149+ return H[n] + M + r"[^\n]*"
150+
151+
152+def java_sources():
153+ for dirpath, dirnames, files in os.walk(ROOT):
154+ dirnames[:] = [d for d in dirnames if d not in (".git", "build", ".gradle")]
155+ for f in files:
156+ if f.endswith((".java", ".kt")):
157+ full = os.path.join(dirpath, f)
158+ with open(full, encoding="utf-8", errors="replace") as fh:
159+ yield os.path.relpath(full, ROOT), fh.read()
160+
161+
162+def check_hooks():
163+ for rel, pat, count in HOOKS:
164+ n = len(re.findall(pat, read(rel), re.M))
165+ if n != count:
166+ fail("hook %r found %d times in %s (expected %d)" % (pat, n, rel, count))
167+ for rel in UPSTREAM:
168+ for line in read(rel).split("\n"):
169+ if "HideSearchBar" in line and "// MultiGram: " not in line:
170+ fail("%s: line without a \"// MultiGram: \" marker: %s" % (rel, line.strip()))
171+ seen = {}
172+ for line in read(DA).split("\n"):
173+ if re.search(r"\bSEARCH_FIELD_HEIGHT\b", line) and "// MultiGram: " not in line:
174+ text = line.strip()
175+ if text not in REVIEWED:
176+ fail("%s: unreviewed use of the bar height (a new upstream use, or a stock line a hook replaced): %s"
177+ % (DA, text))
178+ seen[text] = seen.get(text, 0) + 1
179+ for text, count in REVIEWED.items():
180+ if seen.get(text, 0) != count:
181+ fail("%s: reviewed line %r found %d times (expected %d); review the bar height uses again"
182+ % (DA, text, seen.get(text, 0), count))
183+
184+
185+def need(rel, cond, msg):
186+ if not cond:
187+ fail("%s: %s" % (rel, msg))
188+
189+
190+def check_placement():
191+ src = read(DA)
192+
193+ body = method_body(src, r"^ private int getMaxScrollYOffset\(\) \{")
194+ need(DA, body is not None and re.match(r"\{\s*\n\s*" + hook(11), body),
195+ "the max-scroll hook must be the first statement of getMaxScrollYOffset")
196+
197+ body = method_body(src, r"^ private void updateContextViewPosition\(\) \{")
198+ m = body and re.search(r"totalOffset \+= storiesOverscroll;\s*\n\s*" + hook(12), body)
199+ need(DA, m, "the tabs hook must directly follow \"totalOffset += storiesOverscroll;\" in updateContextViewPosition")
200+ if m:
201+ for later in ["float fadeViewT = totalOffset;", "filterTabsView.setTranslationY("]:
202+ i = body.find(later)
203+ need(DA, i > m.end(), "the tabs hook must come before %r in updateContextViewPosition" % later)
204+
205+ body = method_body(src, r"^ public void onResume\(\) \{")
206+ need(DA, body is not None and re.search(r"super\.onResume\(\);\s*\n\s*" + hook(13), body),
207+ "the refresh hook must directly follow super.onResume() in onResume")
208+ if body is not None:
209+ i, j = body.find("HideSearchBar.refresh("), body.find(".notifyDataSetChanged()")
210+ need(DA, 0 <= i < j, "the refresh hook must run before onResume's notifyDataSetChanged")
211+
212+ # The stock lines of the two checkUi methods are counted on code with comments stripped, so a stock line that an
213+ # update comments out (Telegram often leaves old code commented out here) does not count as present.
214+ body = method_body(src, r"^ private void checkUi_searchFieldVisibility\(\) \{") or ""
215+ code = strip_comments(body)
216+ need(DA, re.search(hook(18), body), "checkUi_searchFieldVisibility must contain %r" % hook(18))
217+ for text in ["fragmentSearchField.setVisibility(alpha > 0 ? View.VISIBLE : View.GONE);",
218+ "animatorSearchButtonVisible.setValue(alpha <= 0.01f, true);"]:
219+ need(DA, code.count(text) == 1, "checkUi_searchFieldVisibility must contain %r once, as code" % text)
220+
221+ stock_factor0 = "final float factor0 = isSupportSearch() ? 1 : 0;"
222+ need(DA, src.count(stock_factor0) == 1 and code.count(stock_factor0) == 1
223+ and code.count("final float factor0 =") == 1
224+ and code.count("final float alpha = factor0 * factor1 * factor2;") == 1,
225+ "%r must remain exactly once, stock and not commented out, as the only factor0 of "
226+ "checkUi_searchFieldVisibility, feeding its stock alpha line (the icon hook replaces the other copy, in "
227+ "checkUi_itemSearchVisibility)" % stock_factor0)
228+ need(DA, body.count("HideSearchBar") == 1, "checkUi_searchFieldVisibility must hold the alpha hook only")
229+
230+ body = method_body(src, r"^ private void checkUi_itemSearchVisibility\(\) \{") or ""
231+ code = strip_comments(body)
232+ need(DA, re.match(r"\{\s*\n\s*" + hook(21) + r"\s*\n\s*"
233+ + re.escape("final float factor1 = animatorSearchButtonVisible.getFloatValue();"), body)
234+ and code.count("final float factor0 =") == 1,
235+ "the icon hook (\"no search\" variant) must be the only factor0 line of checkUi_itemSearchVisibility, "
236+ "opening it, directly followed by the stock factor1 line")
237+ for text in ["final float factor1 = animatorSearchButtonVisible.getFloatValue();",
238+ "final float factor = factor0 * factor1 * factor2 * factor3;",
239+ "FragmentFloatingButton.setAnimatedVisibility(searchItem, factor);"]:
240+ need(DA, code.count(text) == 1,
241+ "checkUi_itemSearchVisibility must contain %r once, as code (the icon hook feeds it)" % text)
242+ need(DA, body.count("HideSearchBar") == 1, "checkUi_itemSearchVisibility must hold the icon hook only")
243+
244+ # Entries left stock on purpose (README: "What still opens search"). Each still opens search with the field.
245+ need(DA, re.search(r"searchItem\.setOnClickListener\(v -> \{\s*\n\s*showSearch\(true, false, true\);", src),
246+ "the hidden header icon's listener must still open search (the topics column's search icon calls it)")
247+ need(TOPICS, "parentDialogsActivity.searchItem.performClick();" in read(TOPICS),
248+ "the topics column's search icon no longer clicks the list's header icon; review the README's "
249+ "\"What still opens search\"")
250+ need(DA, re.search(r"\} else if \(id == 3\) \{\s*\n\s*showSearch\(true, true, true\);", src),
251+ "the Downloads item must still open search (onItemClick id 3 -> showSearch)")
252+ need(DA, re.search(r"public void search\(String query, boolean animated\) \{\s*\n\s*showSearch\(true, false, animated\);",
253+ src),
254+ "tg://search links must still open search (search(query, animated) -> showSearch)")
255+
256+ body = method_body(src, r"^ private void hideActionMode\(boolean animateCheck\) \{") or ""
257+ need(DA, re.search(hook(14), body) and re.search(hook(15), body), "hideActionMode must contain hooks 14 and 15")
258+ body = method_body(src, r"^ private void showOrUpdateActionMode\(long dialogId, View cell\) \{") or ""
259+ need(DA, re.search(hook(16), body) and re.search(hook(17), body),
260+ "showOrUpdateActionMode must contain hooks 16 and 17")
261+ body = method_body(src, r"^ public int getActionBarFullHeight\(\) \{") or ""
262+ need(DA, re.search(re.escape("h += storiesOverscroll;") + r"\s*\n\s*" + hook(1), body),
263+ "the header band hook must directly follow \"h += storiesOverscroll;\" in getActionBarFullHeight")
264+
265+ need(DA, re.search(hook(5) + r"\s*\n\s*" + re.escape("-dp(SEARCH_FIELD_HEIGHT + (hasStories ? DialogStoriesCell.HEIGHT_IN_DP : 0)),")
266+ + r"\s*\n\s*searchAnimationProgress", src),
267+ "the search slide hook must be the start term of the field's lerp (end term and progress stock)")
268+ need(DA, re.search(re.escape("tabsYOffset -= Math.min(") + r"\s*\n\s*" + hook(2) + r"\s*\n\s*" + hook(3), src),
269+ "the two tab lift hooks must directly follow \"tabsYOffset -= Math.min(\"")
270+ need(DA, re.search(hook(4) + r"\s*\n\s*" + re.escape("addH *= rightSlidingDialogContainer.openedProgress;"), src),
271+ "the topics column hook must directly precede \"addH *= rightSlidingDialogContainer.openedProgress;\"")
272+ need(DA, re.search(re.escape("if (!actionModeFullyShowed) {") + r"\s*\n\s*" + hook(7), src),
273+ "the list padding hook must directly follow \"if (!actionModeFullyShowed) {\"")
274+ need(DA, re.search(re.escape("if (backward) {") + r"\s*\n\s*" + hook(8), src),
275+ "the topics column close hook must directly follow \"if (backward) {\"")
276+ need(DA, re.search(re.escape("h += dp(DialogStoriesCell.HEIGHT_IN_DP);") + r"\s*\n\s*\}\s*\n\s*" + hook(6)
277+ + r"\s*\n\s*\}\s*\n\s*" + re.escape("h += actionModeAdditionalHeight;"), src),
278+ "the page height hook must be the last statement of \"if (rightSlidingDialogContainer.hasFragment()) {\" "
279+ "in ContentView.onMeasure, after the stories block")
280+ need(DA, re.search(re.escape("int canScrollDy = -(view.getTop() - pTop) + viewsH;") + r"\s*\n\s*"
281+ + re.escape("if (!rightSlidingDialogContainer.hasFragment() && !(actionBar != null && "
282+ "actionBar.isActionModeShowed())) {") + r"\s*\n\s*" + hook(9) + r"\s*\n\s*\}", src),
283+ "the fling stop hook must be the only statement of the stock if that directly follows "
284+ "\"int canScrollDy = ...\"")
285+ need(DA, re.search(re.escape("if ((viewPage.scroller.isRunning() || dialogStoriesCell.isExpanded()) && "
286+ "!rightSlidingDialogContainer.hasFragment() && !fixScrollYAfterArchiveOpened && "
287+ "!(actionBar != null && actionBar.isActionModeShowed())) {") + r"\s*\n\s*" + hook(10)
288+ + r"\s*\n\s*\}\s*\n\s*" + re.escape("int positiveDy = Math.abs(dy);"), src),
289+ "the fling stop pair hook must be the only statement of the stock if that directly precedes "
290+ "\"int positiveDy = Math.abs(dy);\"")
291+ for n, nxt in [(14, "float finalTranslateListHeight = translateListHeight;"),
292+ (15, "viewPages[0].setTranslationY(0);"),
293+ (16, "if (translateListHeight != 0) {"),
294+ (17, "viewPages[0].setTranslationY(0);")]:
295+ need(DA, re.search(hook(n) + r"\s*\n\s*" + re.escape(nxt), src),
296+ "action mode hook %d must directly precede %r" % (n, nxt))
297+
298+ src = read(FORK)
299+ body = method_body(src, r"^ private void fillSettings\(ArrayList<UItem> items\) \{") or ""
300+ need(FORK, re.search(re.escape('.setChecked(pref("disableGlobalSearch", false)).setMultiline(true));') + r"\s*\n\s*"
301+ + hook(19), body),
302+ "the \"Hide chat list search\" row must directly follow Disable Global Search in fillSettings")
303+ body = method_body(src, r"^ private void onClick\(UItem item, View view, int position, float x, float y\) \{")
304+ need(FORK, body is not None and re.match(r"\{\s*\n\s*final int id = item\.id;\s*\n\s*" + hook(20), body),
305+ "the click hook must directly follow \"final int id = item.id;\" at the start of onClick")
306+
307+
308+def check_class():
309+ raw = read(CLS)
310+ code = strip_comments(raw)
311+ need(CLS, re.search(r'static final String KEY = "%s";' % KEY, code), "KEY must be \"%s\"" % KEY)
312+ for rel, src in java_sources():
313+ if rel != CLS and '"%s"' % KEY in src:
314+ fail("%s names the key \"%s\" (only HideSearchBar may)" % (rel, KEY))
315+ if "/multigram/" not in rel and rel not in UPSTREAM and "multigram.HideSearchBar" in src:
316+ fail("%s names multigram.HideSearchBar (only DialogsActivity and ForkSettingsActivity may)" % rel)
317+ need(CLS, len(re.findall(r"\.putBoolean\(KEY, ", code)) == 1, "exactly one .putBoolean(KEY, ...) expected")
318+ puts = re.findall(r"\.put(?:Boolean|Int|Long|Float|String|StringSet)\(", code)
319+ need(CLS, len(puts) == 1, "writes preferences other than its one key: %s" % puts)
320+ need(CLS, not re.search(r"\b(remove|clear)\(", code), "must not remove or clear preferences")
321+ reads = re.findall(r"getBoolean\(KEY, (\w+)\)", code)
322+ need(CLS, reads and all(r == "false" for r in reads), "every getBoolean(KEY, ...) must default to false")
323+
324+ m = re.search(r"static final int ROW_ID = (\d+);", code)
325+ need(CLS, m, "ROW_ID not found")
326+ if m:
327+ row = int(m.group(1))
328+ fork = strip_comments(read(FORK))
329+ ids = {int(v) for v in re.findall(r"\bID_\w+ = (\d+);", fork)}
330+ ids |= {int(v) for v in re.findall(r"\bMENU_SEARCH = (\d+);", fork)}
331+ need(CLS, row > 100 and row not in ids,
332+ "ROW_ID %d must be above 100 and clear of ForkSettingsActivity's ids" % row)
333+
334+ for name, ret in [("restHeight", r"return hides\(f\) \? 0 : stock;"), ("restAlpha", r"return hides\(f\) \? 0f : stock;")]:
335+ body = method_body(code, r"public static \w+ %s\(DialogsActivity f, \w+ stock\) \{" % name) or ""
336+ need(CLS, re.fullmatch(r"\{\s*" + ret + r"\s*\}", body), "%s must be exactly \"%s\"" % (name, ret.replace("\\", "")))
337+ need(CLS, "LocaleController" not in code, "MultiGram strings are read with Context.getString, not LocaleController")
338+
339+ xml = read(STRINGS)
340+ for n in STRING_NAMES:
341+ need(STRINGS, '<string name="%s">' % n in xml, "string %s is missing" % n)
342+ need(CLS, "R.string.%s)" % n in code, "does not use R.string.%s" % n)
343+ need(FIELD, "multigram" not in read(FIELD).lower(), "FragmentSearchField must stay stock")
344+
345+ kt = read(SETTINGS_BACKUP)
346+ m = re.search(r"ALLOWED_PREFS\s*=\s*listOf\((.*?)\)", kt, re.S)
347+ need(SETTINGS_BACKUP, m and '"mainconfig"' in m.group(1),
348+ "Forkgram's settings export no longer carries mainconfig; update the README's Preferences section")
349+
350+
351+def check_diff(base):
352+ """Every non-blank line added to the hooked upstream files since base carries the marker, or directly follows
353+ an added line that is a "// MultiGram:" comment (the Rebrand hooks' two-line form)."""
354+ for rel in UPSTREAM:
355+ out = subprocess.run(["git", "-C", ROOT, "diff", "-U0", base, "--", rel], capture_output=True, text=True)
356+ if out.returncode != 0:
357+ fail("git diff %s -- %s failed: %s" % (base, rel, out.stderr.strip()))
358+ continue
359+ prev = None
360+ for line in out.stdout.split("\n"):
361+ if line.startswith("@@") or line.startswith("---") or line.startswith("+++"):
362+ prev = None
363+ continue
364+ if not line.startswith("+"):
365+ prev = None
366+ continue
367+ text = line[1:].strip()
368+ if text and "MultiGram:" not in line and not (prev is not None and prev.startswith("// MultiGram:")):
369+ fail("%s: added line without a \"MultiGram:\" marker: %s" % (rel, text))
370+ prev = text
371+
372+
373+def main():
374+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
375+ ap.add_argument("--base", help="git revision of the unpatched Forkgram snapshot (optional diff check)")
376+ args = ap.parse_args()
377+ check_hooks()
378+ check_placement()
379+ check_class()
380+ if args.base:
381+ check_diff(args.base)
382+ for e in errors:
383+ print("hide-search-bar: " + e)
384+ print("hide-search-bar: %s" % ("FAILED, %d problem(s)" % len(errors) if errors else "OK"))
385+ return 1 if errors else 0
386+
387+
388+if __name__ == "__main__":
389+ sys.exit(main())