aboutsummaryrefslogtreecommitdiffziptar.gz
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 07:44:16 +0200
committergodosa <godosa@godosa.eu>2026-10-07 07:44:16 +0200
commit9d5641fe6585a61894f7b7427a68c794e5efe76d (patch)
tree79ebae3131df1c50542420db9e5ecb44e3d22076
parent81d4e80fd5aabe4e80f58e960affa795cf7d34ec (diff)
downloadworkflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.tar.gz
workflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.zip
Denylist guard at commit time (pre-commit + commit-msg hooks); drop publish_snapshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2
-rwxr-xr-x.githooks/commit-msg5
-rwxr-xr-x.githooks/pre-commit5
-rw-r--r--CHANGES.md1
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/manual.md17
-rwxr-xr-xscripts/denylist_check.py158
-rwxr-xr-xscripts/publish_snapshot.py159
-rw-r--r--tests/test_denylist_check.py145
-rw-r--r--tests/test_publish_snapshot.py122
9 files changed, 325 insertions, 289 deletions
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 0000000..1d0bf6f
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Denylist guard (scripts/denylist_check.py): commit message. Enable: git config core.hooksPath .githooks
+s="$(git rev-parse --show-toplevel)/scripts/denylist_check.py"
+[ -f "$s" ] || exit 0
+exec python3 "$s" msg "$1"
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..e5b89ec
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Denylist guard (scripts/denylist_check.py): staged lines + paths. Enable: git config core.hooksPath .githooks
+s="$(git rev-parse --show-toplevel)/scripts/denylist_check.py"
+[ -f "$s" ] || exit 0
+exec python3 "$s" staged
diff --git a/CHANGES.md b/CHANGES.md
index 5f02c1e..e108b07 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,4 +1,5 @@
# Changes (newest first)
+- 2026-10-07 Denylist guard at commit time: `.githooks/pre-commit` (staged lines + paths) and `.githooks/commit-msg` run `scripts/denylist_check.py` against the git-ignored `publish-denylist.local` (worktree top + main tree, plus `git config denylist.file` lists; `word` / `!token` exemption / `#`, case-insensitive; output `word #<line>` only; no list → warning). Enable per clone: `git config core.hooksPath .githooks`; audit: `denylist_check.py tree`. `scripts/publish_snapshot.py` removed (history is public now). Projects: nothing.
- 2026-10-07 Tests use generic fixtures (/h/u, 10.0.0.x) instead of real home-dir and LAN paths (publish-scan clean). Projects: nothing.
- 2026-10-06 Tool path is now `/projects/public/workflow` (was `/projects/workflow`): docs, shared CLAUDE.md, skills, wf-worker agent, messages. `wf projects` / `wf usage --report` scan the grandparent when the parent holds no project. Projects: point `~/.claude` symlinks/settings, `wf-res.service` and any script calling `wf.py` at the new path.
diff --git a/CLAUDE.md b/CLAUDE.md
index d588d7f..9c499c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,6 +4,8 @@ This repo = the tool: `shared/CLAUDE.md` (symlinked as `/projects/CLAUDE.md`, lo
+ `wf.py` / `wf_res.py` / `wflib/`. Reference: `docs/design.md`, `docs/resource-ledger.md`.
No personal data here (project names, tasks, paths under home): the workflow's own tasks live in a
separate private wf project; publishable as is.
+Public by default: never put private names in files or commit messages; the denylist hooks
+(`.githooks`, `scripts/denylist_check.py`; enable: `git config core.hooksPath .githooks`) refuse them.
## Everything here is live
Workers run the main tree's `master` the moment it changes. So:
diff --git a/docs/manual.md b/docs/manual.md
index 2b4594b..26661b6 100644
--- a/docs/manual.md
+++ b/docs/manual.md
@@ -212,14 +212,15 @@ wf add "Cave seams. Close the slit beside the lintel." -p 1 -e '<1h' --model son
model that fits. A sonnet task without an exact Done line comes back as a handback.
- **Updating the tool**: `git -C /projects/public/workflow pull`. Read the top of `CHANGES.md`: each line
ends with "Projects: …", which says what a project must do (usually nothing).
-- **Publishing a public copy**: keep the tool repo private (its history names your projects) and
- share a fresh-history snapshot instead. Put your private words (project names, home paths), one
- per line, in `publish-denylist.local` in the tool repo (git-ignored), then run
- `python3 scripts/publish_snapshot.py [DEST]` (default `~/src/wf-public`). It copies master's
- tree without `inbox.md`, `.worktrees/`, `__pycache__/`, `out/`, refuses if any denylist word is
- in a path or file, and commits once per run (first: "initial public snapshot"; later: the new
- `CHANGES.md` lines). It never pushes and never adds a remote: review DEST, then add the public
- remote and `git push` there yourself.
+- **Public by default**: the tool repo's history is public, so never put private names (project
+ names, home paths, hosts) in files or commit messages. A denylist guard checks each commit: put
+ your private words, one per line (`#` comments, `!token` = exemption), in `publish-denylist.local`
+ in the tool repo (git-ignored; extra lists via `git config --add denylist.file <path>`) and enable
+ the hooks once per clone with `git config core.hooksPath .githooks`. The pre-commit hook scans
+ staged lines and paths, the commit-msg hook the message, both case-insensitive; a hit names the
+ place and `word #<line>` only and refuses the commit (`git commit --no-verify` for a false hit).
+ No list → a warning, the commit goes through. Audit the whole tree with
+ `python3 scripts/denylist_check.py tree`.
## 5. Troubleshooting
diff --git a/scripts/denylist_check.py b/scripts/denylist_check.py
new file mode 100755
index 0000000..08cf985
--- /dev/null
+++ b/scripts/denylist_check.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Denylist guard for a public repo: refuse private words in commits (git hooks in .githooks/).
+
+Usage: denylist_check.py staged | msg FILE | tree [REF]
+ staged added lines + paths of the index (pre-commit hook)
+ msg FILE commit message, '#' lines skipped (commit-msg hook)
+ tree every tracked file of REF (default HEAD): manual audit
+
+Lists (merged): publish-denylist.local in the worktree top and in the main tree (git-ignored),
+plus every `git config denylist.file <path>` (e.g. a global list). Lines: `word` = case-insensitive
+substring; `!token` = exemption (a hit inside an occurrence of token does not count); `#` comments.
+Output names the place and `word #<line>` only, never the word. Exit 0 clean, 1 hits.
+No list at all → warning, exit 0. Bypass once: `git commit --no-verify`.
+Enable in a clone: `git config core.hooksPath .githooks`.
+"""
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+NAME = "publish-denylist.local"
+
+
+def git(*args):
+ r = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True)
+ if r.returncode:
+ raise SystemExit(f"denylist-check: git {args[0]} failed: {r.stderr.decode(errors='replace').strip()}")
+ return r.stdout.decode("utf-8", errors="replace")
+
+
+def parse(text):
+ """([(lineno, word lowercased)], [exempt token lowercased])."""
+ words, exempt = [], []
+ for n, line in enumerate(text.splitlines(), 1):
+ w = line.strip()
+ if not w or w.startswith("#"):
+ continue
+ if w.startswith("!"):
+ if w[1:]:
+ exempt.append(w[1:].lower())
+ else:
+ words.append((n, w.lower()))
+ return words, exempt
+
+
+def _exempt_spans(low, exempt):
+ spans = []
+ for t in exempt:
+ spans += [(m.start(), m.end()) for m in re.finditer(re.escape(t), low)]
+ return spans
+
+
+def hits(text, words, exempt):
+ """Line numbers (in the list) of words found in text outside exempted tokens."""
+ low = text.lower()
+ spans = None
+ out = []
+ for n, w in words:
+ for m in re.finditer(re.escape(w), low):
+ if spans is None:
+ spans = _exempt_spans(low, exempt)
+ if not any(a <= m.start() and m.end() <= b for a, b in spans):
+ out.append(n)
+ break
+ return out
+
+
+def load_lists():
+ """[(label, words, exempt)] of every existing list; label '' for the repo list."""
+ top = Path(git("rev-parse", "--show-toplevel").strip())
+ common = Path(git("rev-parse", "--path-format=absolute", "--git-common-dir").strip())
+ cands = [("", top / NAME), ("", common.parent / NAME)]
+ r = subprocess.run(["git", "config", "--get-all", "denylist.file"], capture_output=True, text=True)
+ cands += [(Path(p).name + " ", Path(p).expanduser()) for p in r.stdout.splitlines() if p.strip()]
+ seen, lists = set(), []
+ for label, p in cands:
+ if not p.is_file() or p.resolve() in seen:
+ continue
+ seen.add(p.resolve())
+ lists.append((label, *parse(p.read_text(errors="replace"))))
+ return lists
+
+
+def scan(items, lists):
+ """items = [(place, text)] → ['place: <label>word #n']; words in place (a path) masked as ***."""
+ allw = sorted({w for _, words, _ in lists for _, w in words}, key=len, reverse=True)
+ mask = re.compile("|".join(map(re.escape, allw)), re.I) if allw else None
+ out = []
+ for place, text in items:
+ for label, words, exempt in lists:
+ out += [f"{mask.sub('***', place)}: {label}word #{n}" for n in hits(text, words, exempt)]
+ return out
+
+
+def staged_items():
+ items, path, line = [], None, 0
+ for name in git("diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR").split("\0"):
+ if name:
+ items.append((f"path {name}", name))
+ for raw in git("diff", "--cached", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=ACMR").splitlines():
+ if raw.startswith("+++ "):
+ path = raw[6:] if raw.startswith("+++ b/") else raw[4:].strip('"')
+ elif raw.startswith("@@"):
+ m = re.match(r"@@ -\S+ \+(\d+)", raw)
+ line = int(m.group(1)) if m else 0
+ elif raw.startswith("+") and path:
+ items.append((f"{path}:{line}", raw[1:]))
+ line += 1
+ return items
+
+
+def msg_items(file):
+ text = Path(file).read_text(errors="replace")
+ items = []
+ for n, l in enumerate(text.splitlines(), 1):
+ if l.startswith("# ------------------------ >8"):
+ break
+ if not l.startswith("#"):
+ items.append((f"commit message:{n}", l))
+ return items
+
+
+def tree_items(ref):
+ items = []
+ for name in git("ls-tree", "-r", "-z", "--name-only", ref).split("\0"):
+ if not name:
+ continue
+ items.append((f"path {name}", name))
+ data = subprocess.run(["git", "cat-file", "blob", f"{ref}:{name}"], capture_output=True).stdout
+ if b"\0" in data[:8000]:
+ continue
+ for n, l in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1):
+ items.append((f"{name}:{n}", l))
+ return items
+
+
+def main(argv):
+ if not argv or argv[0] not in ("staged", "msg", "tree") or (argv[0] == "msg" and len(argv) != 2):
+ print(__doc__.split("\n\n")[1], file=sys.stderr)
+ return 2
+ lists = load_lists()
+ if not lists:
+ print(f"denylist-check: warning: no {NAME} (nor denylist.file): nothing checked", file=sys.stderr)
+ return 0
+ mode = argv[0]
+ items = staged_items() if mode == "staged" else msg_items(argv[1]) if mode == "msg" \
+ else tree_items(argv[1] if len(argv) > 1 else "HEAD")
+ found = scan(items, lists)
+ if found:
+ print("denylist-check: private word found (fix, or `git commit --no-verify` if a false hit):",
+ file=sys.stderr)
+ print("\n".join(found), file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/scripts/publish_snapshot.py b/scripts/publish_snapshot.py
deleted file mode 100755
index bcbb002..0000000
--- a/scripts/publish_snapshot.py
+++ /dev/null
@@ -1,159 +0,0 @@
-#!/usr/bin/env python3
-"""Export a scrubbed snapshot of the tool repo into a separate local git repo (fresh history).
-
-Usage: publish_snapshot.py [DEST] [--src REPO] [--ref REF] [--denylist FILE]
-
-- DEST (default ~/src/wf-public): created and `git init`ed on the first run.
-- The tree of REF (default master) is copied; inbox.md, .worktrees/, __pycache__/, out/ are dropped.
-- Denylist (default <src>/publish-denylist.local, git-ignored): one private word per line, `#` comments;
- matched case-insensitively against every path and file content. Any match → nothing is written, exit 1.
-- First run → one commit 'initial public snapshot'; later runs → one commit whose message is the
- CHANGES.md lines new since the last snapshot; no change → no commit.
-- Never pushes, never adds a remote: pushing is a manual step (docs/manual.md).
-"""
-import argparse
-import io
-import os
-import shutil
-import subprocess
-import sys
-import tarfile
-from pathlib import Path
-
-EXCLUDE_NAMES = {"inbox.md", ".worktrees", "__pycache__", "out"}
-DENYLIST_NAME = "publish-denylist.local"
-
-
-class Fail(Exception):
- pass
-
-
-def git(cwd, *args, data=None):
- r = subprocess.run(["git", *args], cwd=cwd, input=data, capture_output=True)
- if r.returncode:
- raise Fail(f"git {' '.join(args)}: {r.stderr.decode(errors='replace').strip()}")
- return r.stdout
-
-
-def excluded(path):
- parts = path.split("/")
- return any(p in EXCLUDE_NAMES for p in parts)
-
-
-def read_tree(src, ref):
- """{relative path: (bytes, mode)} of REF's tree, excluded paths dropped."""
- tar = tarfile.open(fileobj=io.BytesIO(git(src, "archive", "--format=tar", ref)))
- files = {}
- for m in tar.getmembers():
- if not (m.isfile() or m.issym()) or excluded(m.name):
- continue
- if m.issym():
- files[m.name] = (m.linkname.encode(), "link")
- else:
- files[m.name] = (tar.extractfile(m).read(), m.mode)
- return files
-
-
-def load_denylist(path):
- if not path.is_file():
- raise Fail(f"no denylist at {path} (one private word per line; git-ignored) — refusing to publish")
- words = [w.strip() for w in path.read_text().splitlines()]
- words = [w for w in words if w and not w.startswith("#")]
- if not words:
- raise Fail(f"denylist {path} is empty — refusing to publish")
- return words
-
-
-def scan(files, words):
- """Lines 'path[:line]: word' for every denylist hit."""
- low = [w.lower() for w in words]
- hits = []
- for path in sorted(files):
- for w, wl in zip(words, low):
- if wl in path.lower():
- hits.append(f"{path}: {w} (path)")
- text = files[path][0].decode("utf-8", errors="ignore").lower()
- if not any(wl in text for wl in low):
- continue
- for n, line in enumerate(text.splitlines(), 1):
- for w, wl in zip(words, low):
- if wl in line:
- hits.append(f"{path}:{n}: {w}")
- return hits
-
-
-def changes_lines(data):
- return [l for l in data.decode("utf-8", errors="replace").splitlines() if l.startswith("- ")]
-
-
-def write_tree(dest, files):
- for p in dest.iterdir():
- if p.name == ".git":
- continue
- shutil.rmtree(p) if p.is_dir() and not p.is_symlink() else p.unlink()
- for path, (data, mode) in files.items():
- f = dest / path
- f.parent.mkdir(parents=True, exist_ok=True)
- if mode == "link":
- os.symlink(data.decode(), f)
- else:
- f.write_bytes(data)
- os.chmod(f, 0o755 if mode & 0o111 else 0o644)
-
-
-def publish(src, dest, ref, denylist):
- words = load_denylist(denylist)
- files = read_tree(src, ref)
- hits = scan(files, words)
- if hits:
- raise Fail("denylist matches, nothing published:\n" + "\n".join(hits))
- first = not (dest / ".git").exists()
- if first:
- if dest.exists() and any(dest.iterdir()):
- raise Fail(f"{dest} exists, is not empty and not a git repo")
- dest.mkdir(parents=True, exist_ok=True)
- git(dest, "init", "-q", "-b", "master")
- old_changes = []
- else:
- old = dest / "CHANGES.md"
- old_changes = changes_lines(old.read_bytes()) if old.is_file() else []
- write_tree(dest, files)
- git(dest, "add", "-A")
- if not first and not git(dest, "status", "--porcelain").strip():
- return "nothing new: no commit"
- if first:
- msg = "initial public snapshot"
- else:
- new = [l for l in changes_lines(files.get("CHANGES.md", (b"", 0))[0]) if l not in set(old_changes)]
- msg = "public snapshot\n\n" + ("\n".join(new) if new else "- (no new CHANGES.md lines)")
- ident = [] # a fresh dest has no identity of its own: commit as the source repo's user
- for key in ("user.name", "user.email"):
- r = subprocess.run(["git", "config", key], cwd=src, capture_output=True, text=True)
- if r.stdout.strip():
- ident += ["-c", f"{key}={r.stdout.strip()}"]
- git(dest, *ident, "commit", "-q", "-F", "-", data=msg.encode())
- sha = git(dest, "rev-parse", "--short", "HEAD").decode().strip()
- return f"committed {sha} in {dest} ({'first' if first else 'update'}; not pushed — see docs/manual.md)"
-
-
-def main(argv=None):
- here = Path(__file__).resolve().parent.parent
- ap = argparse.ArgumentParser(prog="publish_snapshot.py", description=__doc__.splitlines()[0])
- ap.add_argument("dest", nargs="?", default=str(Path.home() / "src" / "wf-public"),
- help="target repo (default ~/src/wf-public)")
- ap.add_argument("--src", default=str(here), help="tool repo (default: this script's repo)")
- ap.add_argument("--ref", default="master", help="source ref (default master)")
- ap.add_argument("--denylist", help=f"private-word file (default <src>/{DENYLIST_NAME})")
- a = ap.parse_args(argv)
- src = Path(a.src).expanduser().resolve()
- deny = Path(a.denylist).expanduser() if a.denylist else src / DENYLIST_NAME
- try:
- print(publish(src, Path(a.dest).expanduser().resolve(), a.ref, deny))
- except Fail as e:
- print(f"wf: {e}", file=sys.stderr)
- return 1
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/tests/test_denylist_check.py b/tests/test_denylist_check.py
new file mode 100644
index 0000000..3a7fa36
--- /dev/null
+++ b/tests/test_denylist_check.py
@@ -0,0 +1,145 @@
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "scripts"))
+import denylist_check as D # noqa: E402
+
+ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1"}
+
+
+def git(cwd, *args, check=True):
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV, check=check)
+
+
+class ParseTest(unittest.TestCase):
+ def test_words_comments_exemptions(self):
+ words, exempt = D.parse("# c\nSecretProj\n\n Bob \n!bobcat\n")
+ self.assertEqual(words, [(2, "secretproj"), (4, "bob")])
+ self.assertEqual(exempt, ["bobcat"])
+
+ def test_hits_case_insensitive_and_exempt(self):
+ words, exempt = [(1, "bob"), (2, "secretproj")], ["bobcat"]
+ self.assertEqual(D.hits("a BOB and a Bobcat", words, exempt), [1])
+ self.assertEqual(D.hits("only a bobcat here", words, exempt), [])
+ self.assertEqual(D.hits("x SecretProj y", words, exempt), [2])
+ self.assertEqual(D.hits("clean", words, exempt), [])
+
+
+class HookTest(unittest.TestCase):
+ """Real temp repo with the committed hook dir; commits through git itself."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.repo = Path(self.tmp.name) / "r"
+ self.repo.mkdir()
+ git(self.repo, "init", "-q", "-b", "master")
+ shutil.copytree(ROOT / ".githooks", self.repo / ".githooks")
+ (self.repo / "scripts").mkdir()
+ shutil.copy(ROOT / "scripts" / "denylist_check.py", self.repo / "scripts")
+ (self.repo / ".gitignore").write_text("publish-denylist.local\n")
+ git(self.repo, "config", "core.hooksPath", ".githooks")
+ (self.repo / "publish-denylist.local").write_text("# private\nSecretProj\n!secretprojector\n")
+ self.put("a.txt", "hello\n")
+ git(self.repo, "add", ".githooks", "scripts", ".gitignore")
+ r = self.commit("init")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def put(self, path, text):
+ f = self.repo / path
+ f.parent.mkdir(parents=True, exist_ok=True)
+ f.write_text(text)
+ git(self.repo, "add", path)
+
+ def commit(self, msg, cwd=None):
+ return git(cwd or self.repo, "commit", "-q", "-m", msg, check=False)
+
+ def head(self, cwd=None):
+ return git(cwd or self.repo, "log", "--format=%s").stdout.splitlines()
+
+ def test_content_hit_blocks_without_printing_word(self):
+ self.put("b.txt", "x\nsee secretproj docs\n")
+ r = self.commit("add b")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("b.txt:2: word #2", r.stderr)
+ self.assertNotIn("secretproj", r.stderr.lower())
+ self.assertEqual(self.head(), ["init"])
+
+ def test_path_hit_blocks(self):
+ self.put("docs/SECRETPROJ-notes.md", "clean\n")
+ r = self.commit("add notes")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("path docs/***-notes.md: word #2", r.stderr)
+ self.assertNotIn("secretproj", r.stderr.lower())
+
+ def test_message_hit_blocks(self):
+ self.put("b.txt", "clean\n")
+ r = self.commit("port from SecretProj")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("commit message:1: word #2", r.stderr)
+ self.assertEqual(self.head(), ["init"])
+
+ def test_clean_and_exempt_pass(self):
+ self.put("b.txt", "the secretprojector is fine\n")
+ r = self.commit("clean msg")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(self.head(), ["clean msg", "init"])
+
+ def test_only_added_lines_count(self):
+ (self.repo / "publish-denylist.local").write_text("") # let an old hit in
+ self.put("old.txt", "SecretProj legacy\n")
+ self.assertEqual(self.commit("old").returncode, 0)
+ (self.repo / "publish-denylist.local").write_text("SecretProj\n")
+ self.put("old.txt", "SecretProj legacy\nnew clean line\n")
+ r = self.commit("touch old")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_missing_denylist_warns_not_fails(self):
+ (self.repo / "publish-denylist.local").unlink()
+ self.put("b.txt", "SecretProj\n")
+ r = self.commit("no list")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn("warning: no publish-denylist.local", r.stderr)
+
+ def test_linked_worktree_uses_main_tree_list(self):
+ wt = Path(self.tmp.name) / "wt"
+ git(self.repo, "worktree", "add", "-q", str(wt), "-b", "topic")
+ self.assertFalse((wt / "publish-denylist.local").exists())
+ (wt / "c.txt").write_text("SecretProj\n")
+ git(wt, "add", "c.txt")
+ r = self.commit("wt", cwd=wt)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("c.txt:1: word #2", r.stderr)
+
+ def test_extra_list_from_git_config_merged(self):
+ glob = Path(self.tmp.name) / "global.txt"
+ glob.write_text("Alpha\n")
+ git(self.repo, "config", "--add", "denylist.file", str(glob))
+ self.put("b.txt", "alpha\n")
+ r = self.commit("b")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("b.txt:1: global.txt word #1", r.stderr)
+ self.assertNotIn("alpha", r.stderr.lower())
+
+ def test_tree_mode_scans_tracked_files(self):
+ (self.repo / "publish-denylist.local").write_text("")
+ self.put("old.txt", "x SecretProj\n")
+ self.assertEqual(self.commit("old").returncode, 0)
+ (self.repo / "publish-denylist.local").write_text("SecretProj\n")
+ r = subprocess.run([sys.executable, "scripts/denylist_check.py", "tree"], cwd=self.repo,
+ capture_output=True, text=True, env=ENV)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("old.txt:1: word #1", r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_publish_snapshot.py b/tests/test_publish_snapshot.py
deleted file mode 100644
index 3243d4b..0000000
--- a/tests/test_publish_snapshot.py
+++ /dev/null
@@ -1,122 +0,0 @@
-import os
-import subprocess
-import sys
-import tempfile
-import unittest
-from pathlib import Path
-
-SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "publish_snapshot.py"
-ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
- "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": "/dev/null"}
-
-
-def git(cwd, *args):
- return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV, check=True).stdout
-
-
-class PublishSnapshotTest(unittest.TestCase):
- def setUp(self):
- self.tmp = tempfile.TemporaryDirectory()
- t = Path(self.tmp.name)
- self.src, self.dest, self.deny = t / "tool", t / "pub", t / "deny.txt"
- self.src.mkdir()
- git(self.src, "init", "-q", "-b", "master")
- self.put({"wf.py": "print('hi')\n", "CHANGES.md": "# Changes\n\n- 2026-01-01 first.\n",
- "wflib/a.py": "x = 1\n", "docs/d.md": "doc\n",
- "inbox.md": "tracked inbox\n", "out/log": "x\n", "sub/__pycache__/c.pyc": "bin\n"})
- (self.src / "wf.py").chmod(0o755)
- self.commit("one")
- self.deny.write_text("# private\nSecretProj\n\n")
-
- def tearDown(self):
- self.tmp.cleanup()
-
- def put(self, files):
- for p, text in files.items():
- f = self.src / p
- f.parent.mkdir(parents=True, exist_ok=True)
- f.write_text(text)
-
- def commit(self, msg):
- git(self.src, "add", "-A")
- git(self.src, "commit", "-qm", msg)
-
- def run_it(self, *extra):
- return subprocess.run([sys.executable, str(SCRIPT), str(self.dest), "--src", str(self.src),
- "--denylist", str(self.deny), *extra], capture_output=True, text=True, env=ENV)
-
- def files(self):
- return sorted(git(self.dest, "ls-files").split())
-
- def test_first_run_one_commit_excludes(self):
- r = self.run_it()
- self.assertEqual(r.returncode, 0, r.stderr)
- self.assertEqual(self.files(), ["CHANGES.md", "docs/d.md", "wf.py", "wflib/a.py"])
- self.assertEqual(git(self.dest, "log", "--format=%s").splitlines(), ["initial public snapshot"])
- self.assertTrue(os.access(self.dest / "wf.py", os.X_OK))
- self.assertEqual(git(self.dest, "remote"), "")
-
- def test_denylist_hit_content_and_path_writes_nothing(self):
- self.put({"docs/d.md": "doc\nsee secretproj here\n", "SecretProj.md": "x\n"})
- self.commit("leak")
- r = self.run_it()
- self.assertEqual(r.returncode, 1)
- self.assertIn("docs/d.md:2: SecretProj", r.stderr)
- self.assertIn("SecretProj.md: SecretProj (path)", r.stderr)
- self.assertFalse(self.dest.exists())
-
- def test_uncommitted_files_not_published(self):
- (self.src / "wip.py").write_text("SecretProj\n")
- r = self.run_it()
- self.assertEqual(r.returncode, 0, r.stderr)
- self.assertNotIn("wip.py", self.files())
-
- def test_missing_or_empty_denylist_refuses(self):
- self.deny.unlink()
- r = self.run_it()
- self.assertEqual(r.returncode, 1)
- self.assertIn("no denylist", r.stderr)
- self.deny.write_text("# only comments\n")
- self.assertEqual(self.run_it().returncode, 1)
- self.assertFalse(self.dest.exists())
-
- def test_second_run_commit_message_new_changes_lines(self):
- self.run_it()
- self.put({"CHANGES.md": "# Changes\n\n- 2026-01-03 third.\n- 2026-01-02 second.\n- 2026-01-01 first.\n",
- "wflib/b.py": "y = 2\n"})
- (self.src / "docs/d.md").unlink()
- self.commit("two")
- r = self.run_it()
- self.assertEqual(r.returncode, 0, r.stderr)
- self.assertEqual(git(self.dest, "log", "-1", "--format=%B").strip(),
- "public snapshot\n\n- 2026-01-03 third.\n- 2026-01-02 second.")
- self.assertEqual(self.files(), ["CHANGES.md", "wf.py", "wflib/a.py", "wflib/b.py"])
- self.assertEqual(len(git(self.dest, "log", "--format=%h").split()), 2)
-
- def test_no_change_no_commit(self):
- self.run_it()
- r = self.run_it()
- self.assertEqual(r.returncode, 0, r.stderr)
- self.assertIn("nothing new", r.stdout)
- self.assertEqual(len(git(self.dest, "log", "--format=%h").split()), 1)
-
- def test_ref_option_and_never_touches_remote(self):
- self.run_it()
- git(self.dest, "remote", "add", "home", "/nonexistent")
- self.put({"wflib/a.py": "x = 2\n"})
- self.commit("two")
- self.assertEqual(self.run_it("--ref", "HEAD~1").stdout.strip(), "nothing new: no commit")
- r = self.run_it()
- self.assertIn("committed", r.stdout)
- self.assertEqual(git(self.dest, "remote").split(), ["home"])
-
- def test_nonempty_non_git_dest_refused(self):
- self.dest.mkdir()
- (self.dest / "keep.txt").write_text("x")
- r = self.run_it()
- self.assertEqual(r.returncode, 1)
- self.assertIn("not a git repo", r.stderr)
-
-
-if __name__ == "__main__":
- unittest.main()