{"id":4158,"date":"2026-08-10T03:44:19","date_gmt":"2026-08-10T03:44:19","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/08\/10\/my-commit-message-script-has-8-assertions-in-selftest-none-of-them-touch-the-code-that-can-actually-fail\/"},"modified":"2026-08-10T03:44:19","modified_gmt":"2026-08-10T03:44:19","slug":"my-commit-message-script-has-8-assertions-in-selftest-none-of-them-touch-the-code-that-can-actually-fail","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/08\/10\/my-commit-message-script-has-8-assertions-in-selftest-none-of-them-touch-the-code-that-can-actually-fail\/","title":{"rendered":"My Commit-Message Script Has 8 Assertions in &#8211;selftest. None of Them Touch the Code That Can Actually Fail."},"content":{"rendered":"<div>\n<div><\/div>\n<p>That&#8217;s a held index lock hanging <code>git diff<\/code>, an empty staging area, a <code>claude -p<\/code> call that times out, one that exits non-zero, and one where the <code>claude<\/code> binary isn&#8217;t even on <code>PATH<\/code>. Real scenarios \u2014 the timeout on this exact <code>git diff --staged<\/code> call was itself a bug I&#8217;d already found and fixed once (<code>docs\/project_notes\/bugs.md<\/code>, 2026-08-06: a prior fix claimed to add a timeout to &#8220;both&#8221; subprocess calls in this file and only actually touched one).<\/p>\n<p>Here&#8217;s the entire <code>--selftest<\/code> block: <\/p>\n<div>\n<pre><code><span>if<\/span> <span>\"<\/span><span>--selftest<\/span><span>\"<\/span> <span>in<\/span> <span>sys<\/span><span>.<\/span><span>argv<\/span><span>:<\/span> <span>_CASES<\/span> <span>=<\/span> <span>[<\/span> <span>(<\/span><span>\"<\/span><span>co-authored-by: claude &lt;noreply@anthropic.com&gt;<\/span><span>\"<\/span><span>,<\/span> <span>True<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>\ud83e\udd16 generated with [claude code](https:\/\/claude.ai\/code)<\/span><span>\"<\/span><span>,<\/span> <span>True<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>generated by claude code<\/span><span>\"<\/span><span>,<\/span> <span>True<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>written by an ai<\/span><span>\"<\/span><span>,<\/span> <span>True<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>fix: retry llm calls on 429 with backoff<\/span><span>\"<\/span><span>,<\/span> <span>False<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>docs: add claude code hook install instructions<\/span><span>\"<\/span><span>,<\/span> <span>False<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>feat: wire up claude code review workflow for prs<\/span><span>\"<\/span><span>,<\/span> <span>False<\/span><span>),<\/span> <span>(<\/span><span>\"<\/span><span>fix: handle claude code mcp timeout in server.py<\/span><span>\"<\/span><span>,<\/span> <span>False<\/span><span>),<\/span> <span>]<\/span> <span>for<\/span> <span>line<\/span><span>,<\/span> <span>expect_stripped<\/span> <span>in<\/span> <span>_CASES<\/span><span>:<\/span> <span>got<\/span> <span>=<\/span> <span>bool<\/span><span>(<\/span><span>_STRIP_RE<\/span><span>.<\/span><span>search<\/span><span>(<\/span><span>line<\/span><span>))<\/span> <span>assert<\/span> <span>got<\/span> <span>==<\/span> <span>expect_stripped<\/span><span>,<\/span> <span>(<\/span><span>line<\/span><span>,<\/span> <span>got<\/span><span>,<\/span> <span>expect_stripped<\/span><span>)<\/span> <span>print<\/span><span>(<\/span><span>\"<\/span><span>selftest ok<\/span><span>\"<\/span><span>)<\/span> <span>raise<\/span> <span>SystemExit<\/span><span>(<\/span><span>0<\/span><span>)<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Eight assertions, all against <code>_STRIP_RE<\/code> \u2014 the regex that strips AI-attribution lines from whatever <code>claude -p<\/code> returns. That regex is worth testing; it&#8217;s regressed twice before (<code>bugs.md<\/code>, 2026-07-22 and 2026-07-26, both bare-substring over-matching). But it&#8217;s a pure string-matching function with zero dependency on <code>git<\/code>, <code>claude<\/code>, or the network. <code>selftest ok<\/code> tells me the filter still behaves. It tells me nothing about whether this script survives a timed-out <code>git diff<\/code>, a <code>claude<\/code> binary that isn&#8217;t installed, or a non-zero exit \u2014 the five branches above that are the actual reason those <code>except<\/code> clauses exist.<\/p>\n<p>Compare that to the other two files&#8217; <code>--selftest<\/code> blocks, which I&#8217;d written the same week and apparently held to a different bar. <code>publish_devto.py<\/code> stubs <code>urllib.request.urlopen<\/code> itself to drive its failure paths: <\/p>\n<div>\n<pre><code><span>def<\/span> <span>_fake_url_error<\/span><span>(<\/span><span>req<\/span><span>,<\/span> <span>timeout<\/span><span>=<\/span><span>30<\/span><span>):<\/span> <span>raise<\/span> <span>urllib<\/span><span>.<\/span><span>error<\/span><span>.<\/span><span>URLError<\/span><span>(<\/span><span>\"<\/span><span>timed out<\/span><span>\"<\/span><span>)<\/span> <span>urllib<\/span><span>.<\/span><span>request<\/span><span>.<\/span><span>urlopen<\/span> <span>=<\/span> <span>_fake_url_error<\/span> <span>try<\/span><span>:<\/span> <span>try<\/span><span>:<\/span> <span>already_published<\/span><span>(<\/span><span>\"<\/span><span>k<\/span><span>\"<\/span><span>,<\/span> <span>\"<\/span><span>anything<\/span><span>\"<\/span><span>)<\/span> <span>assert<\/span> <span>False<\/span><span>,<\/span> <span>\"<\/span><span>URLError is the ambiguous case \u2014 must raise, not return None<\/span><span>\"<\/span> <span>except<\/span> <span>RuntimeError<\/span><span>:<\/span> <span>pass<\/span> <span>finally<\/span><span>:<\/span> <span>urllib<\/span><span>.<\/span><span>request<\/span><span>.<\/span><span>urlopen<\/span> <span>=<\/span> <span>_orig_urlopen<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>And a missing-credential case: <\/p>\n<div>\n<pre><code><span>_saved_dev_key<\/span> <span>=<\/span> <span>os<\/span><span>.<\/span><span>environ<\/span><span>.<\/span><span>pop<\/span><span>(<\/span><span>\"<\/span><span>DEV_TO_API<\/span><span>\"<\/span><span>,<\/span> <span>None<\/span><span>)<\/span> <span>try<\/span><span>:<\/span> <span>try<\/span><span>:<\/span> <span>main<\/span><span>(<\/span><span>\"<\/span><span>this-file-does-not-exist.md<\/span><span>\"<\/span><span>)<\/span> <span>assert<\/span> <span>False<\/span><span>,<\/span> <span>\"<\/span><span>missing DEV_TO_API must exit, not silently proceed<\/span><span>\"<\/span> <span>except<\/span> <span>SystemExit<\/span> <span>as<\/span> <span>e<\/span><span>:<\/span> <span>assert<\/span> <span>e<\/span><span>.<\/span><span>code<\/span> <span>is<\/span> <span>not<\/span> <span>None<\/span> <span>and<\/span> <span>\"<\/span><span>DEV_TO_API not set<\/span><span>\"<\/span> <span>in<\/span> <span>str<\/span><span>(<\/span><span>e<\/span><span>.<\/span><span>code<\/span><span>),<\/span> <span>e<\/span><span>.<\/span><span>code<\/span> <span>except<\/span> <span>KeyError<\/span><span>:<\/span> <span>assert<\/span> <span>False<\/span><span>,<\/span> <span>\"<\/span><span>must exit through ERROR: convention, not a bare KeyError<\/span><span>\"<\/span> <span>finally<\/span><span>:<\/span> <span>if<\/span> <span>_saved_dev_key<\/span> <span>is<\/span> <span>not<\/span> <span>None<\/span><span>:<\/span> <span>os<\/span><span>.<\/span><span>environ<\/span><span>[<\/span><span>\"<\/span><span>DEV_TO_API<\/span><span>\"<\/span><span>]<\/span> <span>=<\/span> <span>_saved_dev_key<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p><code>server.py<\/code> does the same thing to its own <code>_gh<\/code>\/<code>_dev<\/code> helpers \u2014 swap in a fake, pop the credential, assert the exception shape. Both of those got this treatment specifically because a missing-credential <code>KeyError<\/code> was a real, previously-shipped bug (<code>bugs.md<\/code>, 2026-08-09) that a stub test could catch and a plain read-through couldn&#8217;t.<\/p>\n<p>Going back to <code>git_commit.py<\/code> with that pattern in mind, the fix isn&#8217;t hard \u2014 <code>subprocess.check_output<\/code> is exactly as mockable as <code>urllib.request.urlopen<\/code>: <\/p>\n<div>\n<pre><code><span>def<\/span> <span>_fake_timeout<\/span><span>(<\/span><span>*<\/span><span>a<\/span><span>,<\/span> <span>**<\/span><span>k<\/span><span>):<\/span> <span>raise<\/span> <span>subprocess<\/span><span>.<\/span><span>TimeoutExpired<\/span><span>(<\/span><span>cmd<\/span><span>=<\/span><span>a<\/span><span>[<\/span><span>0<\/span><span>],<\/span> <span>timeout<\/span><span>=<\/span><span>20<\/span><span>)<\/span> <span>_orig_check_output<\/span> <span>=<\/span> <span>subprocess<\/span><span>.<\/span><span>check_output<\/span> <span>subprocess<\/span><span>.<\/span><span>check_output<\/span> <span>=<\/span> <span>_fake_timeout<\/span> <span>try<\/span><span>:<\/span> <span># exercise the git-diff-timeout branch here <\/span> <span>...<\/span> <span>finally<\/span><span>:<\/span> <span>subprocess<\/span><span>.<\/span><span>check_output<\/span> <span>=<\/span> <span>_orig_check_output<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>But it needs somewhere to plug in. <code>publish_devto.py<\/code> and <code>server.py<\/code> both wrap their risky calls in named functions \u2014 <code>already_published()<\/code>, <code>main()<\/code>, <code>_gh()<\/code>, <code>_dev()<\/code> \u2014 so a selftest can import or call them directly and swap out one dependency. <code>git_commit.py<\/code> has no such boundary. It&#8217;s twenty lines of top-level script: read the diff, call <code>claude -p<\/code>, print the result, all at module scope, guarded only by an early <code>if \"--selftest\" in sys.argv: ... raise SystemExit(0)<\/code> at the top. There&#8217;s no function to call with a stubbed <code>subprocess.check_output<\/code> without either refactoring the five try\/except blocks into a callable or monkeypatching <code>subprocess<\/code> module-wide before the script&#8217;s own top-level code runs \u2014 which, for a file this makes <code>--selftest<\/code> exit before ever reaching, doesn&#8217;t actually help either.<\/p>\n<p>So the honest fix here is two-part, not one: wrap the diff-read-and-generate logic in a function the way its two siblings already do, then give that function the same stub-and-assert treatment <code>already_published()<\/code> and <code>_gh()<\/code>\/<code>_dev()<\/code> got. I haven&#8217;t done that yet \u2014 flagging it here as the concrete next step rather than a vague &#8220;add more tests&#8221; note, since the shape of the fix (extract a function, then mock its one real dependency) is already sitting right there in the same repo, in the two files that did it first.<\/p>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/enjoy_kumawat\/my-commit-message-script-has-8-assertions-in-selftest-none-of-them-touch-the-code-that-can-4ji4\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>That&#8217;s a held index lock hanging git diff, an empty staging area, a claude -p call that times out, one that exits non-zero, and one where the claude binary isn&#8217;t even on PATH. Real scenarios \u2014 the timeout on this exact git diff &#8211;staged call was itself a bug I&#8217;d already found and fixed once [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2648,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-4158","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/4158","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=4158"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/4158\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/2648"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=4158"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=4158"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=4158"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}