タグ: トラブルシューティング

  • 勉強法(2) 測らないと、分かったことにならない

    勉強法(2) 測らないと、分かったことにならない

    「分かった」と思った直後に、間違っていたことが何度かありました。

    いちばん効いたのは、自分の理解を外から確かめる手順を持つことでした。今回はその話です。

    この記事で扱うこと

    このサイトで実際にやった「確かめ方」を3つ。どれも、確かめていなかったら間違ったまま進んでいたものです。

    1. 数字を出したら、始まりと終わりを見る

    記事の読み進み具合を出すバーを作りました。動いています。そう思っていたら、開いた瞬間の数字が目に入りました。

    ページを開いた瞬間に9.5%を指していました。まだ何もしていません。

    調べると、計算そのものは正しかった。画面の下端を基準にしていたので、開いた時点で本文の一部がすでに映っていたのです。「画面に映った本文の割合」としては9.5%が正確な値でした。

    式が答えている問いと、表示が名乗っている問いが違った

    表示が名乗っていたのは「読み進み具合」です。読み進み具合なら、読み始める前は0のはずでした。

    計算間違いではなく、意味のずれです。数字が合っているかを確かめても見つかりません。「0から始まっているか」「最後で100になるか」という、両端を見る確かめ方でしか出てきませんでした。

    2. 試した環境が、確かめたい条件を満たしているか

    同じ読了判定で、もっと危ない間違いをしました。

    ブラウザを自動で動かして、記事を最後までスクロールさせました。読了として記録されません。「記録されない不具合がある」と結論を出しかけました。

    念のため、スクロールのイベントが何回起きたかを数えました。

    発火回数: 0

    自動で動かしているタブでは、スクロールのイベントがそもそも発生していませんでした。不具合が無くても、同じ結果になります。

    結論を取り下げるほうが、直すより先だった

    この時点で分かったのは「読了が記録されない」ではなく、「この方法では確かめられない」ということだけです。

    ここで直しにかかっていたら、動いているものを壊していました。試した環境が条件を満たしているかは、結果より先に確かめる必要がありました。

    そのあとは、ブラウザで試すのをやめて、実際の寸法を使って計算で確かめました。

    3. 原因が分からないときは、半分に割る

    コードを含む記事を投稿しようとしたら、サーバーに弾かれました。403が返るだけで、理由は何も出ません。

    本文は4500字あります。どこが原因かは分かりません。

    そこで、本文を4つに割って1つずつ試しました。

    試した範囲 結果
    1/4ずつ(4回) 2番目と3番目が弾かれる
    そのうち1つを8分割 2か所に絞れた
    怪しい行を単体で 2つの書き方に特定

    最終的に、引っかかっていたのは2つの書き方だけだと分かりました。理由を推測せずに済んだのは、割って試したからです。

    推測で当てにいくと、外れたことにも気づけない

    最初は「コードの記号が多いからだろう」と考えて、記号を別の表記に置き換えました。それでも弾かれました。

    置き換えが効かなかったこと自体が情報で、相手が表記を戻してから見ていると分かりました。推測が外れたと分かったのは、確かめる手順があったからです。

    確かめ方は、覚えるものではなかった

    3つ並べてみると、覚えている知識はほとんど出てきません。

    場面 やったこと
    数字を出した 両端の値を見た
    試したら失敗した 試し方が成立しているか数えた
    原因が分からない 範囲を半分に割った

    どれも技術というより手順です。そして3つとも、自分の理解を疑うところから始まっています。

    今回の学び

    まず、「動いた」は「合っている」ではないということ。進捗バーは最初から最後まで動いていました。動きを見ているかぎり、9.5%から始まっていることには気づけません。

    次に、確かめた結果より先に、確かめ方を確かめること。0回しか起きていないイベントを待っていたら、どんな実装でも同じ結果になります。出た結果を信じる前に、その試し方で違いが出るのかを見る必要がありました。

    そして、分からないときは推測ではなく分割すること。4500字から2つの書き方まで絞るのに、知識は要りませんでした。要ったのは、半分に割って試す根気だけでした。

  • ソース解説(5) 文字列を自分で組み立てる場所

    ソース解説(5) 文字列を自分で組み立てる場所

    このサイトで、HTMLを文字列として自分で組み立てている場所は2つあります。RSSフィードとサイトマップです。

    画面に出るページは、部品を組み合わせれば勝手に出来上がります。この2つは違います。1行ずつ並べて、最後に文字列としてつなぎます。

    この記事で扱うこと

    自分で組み立てる場所に何が必要になるか。具体的には、エスケープ、日付の形式、そして省略していい項目とそうでない項目の見分け方です。

    1文字でフィード全体が壊れる

    まずここから。記事のタイトルに「&」や「<」が入ることは普通にあります。

    順番 元の文字 書き出す形
    1 & &amp;
    2 < &lt;
    3 > &gt;
    4 二重引用符 &quot;
    5 単引用符 &apos;

    これを通さずにタイトルを書き込むと、XMLとして読めなくなります。

    壊れ方が「1記事だけ出ない」ではない

    HTMLは多少おかしくてもブラウザが解釈してくれますが、XMLは1か所でも壊れると全体が読めません。

    つまり、タイトルに「&」が入った記事を1本出した瞬間に、購読アプリ側では何も表示されなくなります。その1本だけが抜けるのではありません。

    そして & の置き換えがいちばん最初にあるのが重要です。後ろに回すと、< を &lt; に変えた後の & をもう一度変換してしまい、&amp;lt; になります。

    日付の名前を、自分で持っている

    RSSが定める日付は「Sun, 24 Aug 2026 13:27:40 GMT」の形です。英語の曜日と月名を使います。

    const DAYS = ["Sun", "Mon", "Tue", ...];
    const MONTHS = ["Jan", "Feb", "Mar", ...];

    日付を文字にする機能はブラウザにもありますが、結果が端末の言語設定に左右されます。日本語の環境で「日」「8月」と出てしまうと、フィードとしては読めません。

    形式が決まっているものは、こちらで名前を持っているほうが確実でした。

    時間帯の表記が無い文字列

    WordPressから届く日付は「2026-08-24T13:27:40」の形です。末尾に時間帯の表記がありません。

    const iso = HAS_TIMEZONE.test(gmt)
      ? gmt
      : gmt + "Z";

    すでに時間帯が付いているかを確かめて、無ければ Z(世界標準時)を足してから解釈します。

    HAS_TIMEZONE は、末尾に時間帯の表記があるかを判定する正規表現です。実物は1行に書いてありますが、長くて読む邪魔になるので、ここでは名前を付けて抜き出しています。

    足さないと、見る人の環境で日付がずれる

    時間帯の表記が無い文字列は、解釈する側の地域として扱われることがあります。日本で動かすと9時間ずれた値になります。

    表示は普通に出ます。「少し前の日付だな」と思うだけで、ずれていることに気づけません。

    無い項目は、書かない

    組み立てる側でいちばん判断が要るのがここでした。

    pubDate ? "<pubDate>" + pubDate + "</pubDate>" : null,
    summary ? "<description>" + summary + "</description>" : null,
    

    そして最後に、null の行を落としてからつなぎます。

    .filter((line): line is string => line !== null)
    .join("\n")

    日付や説明が取れなかったとき、空のまま項目だけ書くこともできます。そうしないのは、空の項目が「値が無い」ではなく「空という値がある」と読まれるからです。

    サイトマップ側でも同じ判断をしています。

    if (post.modified) {
      entry.lastModified = new Date(post.modified);
    }
    無いときに今日の日付を入れてはいけない

    更新日が取れないときに今日の日付を埋めると、「毎日更新されている」と伝えることになります。

    形としては揃いますが、内容は嘘です。何度か見に来られて、毎回変わっていないと分かれば、そのうち信用されなくなります。書かないほうが正確でした。

    更新日時に、生成時刻を使わない

    フィード全体の更新日時も同じ考え方です。

    const lastBuildDate = toRfc822(posts[0]?.dateGmt);

    作った瞬間の時刻ではなく、いちばん新しい記事の日付を使っています。

    生成時刻にすると、中身がまったく変わっていなくても毎回新しく見えます。フィードは1時間ごとに作り直しているので、記事を1本も書いていない日でも「24回更新された」ことになります。

    作り直す間隔を、用途で分けている

    このサイトには、作り直す間隔が3種類あります。

    対象 間隔 理由
    記事ページ・一覧 5分 読む人が直接見る
    RSS 1時間 購読アプリ側も数十分おきにしか来ない
    サイトマップ 1時間 載っているかだけが問題

    RSSを5分にしても、届く速さは変わりません。相手が見に来る間隔のほうが長いからです。短くして得があるのは、読む人が直接開くページだけでした。

    サイトマップは、XMLを書いていない

    最後に1つ。フィードは文字列を組み立てていますが、サイトマップは違います。

    export default async function sitemap():
      Promise<MetadataRoute.Sitemap> {
      return [...staticEntries, ...glossaryEntries, ...postEntries];
    }

    配列を返すだけで、XMLは自動で作られます。RSSに同じ仕組みが無いので、そちらだけ手で書いています。

    手で書ける場所では、手で書かない。エスケープも日付の形式も、自分で持てば自分で間違えられるようになります。

    今回の学び

    まず、厳しい形式ほど、壊れ方が全か無かになるということ。XMLは1文字で全体が読めなくなります。多少おかしくても表示されるHTMLに慣れていると、この差は見落とします。

    次に、値が無いときに、形を揃えるために埋めないこと。更新日が無いのに今日の日付を入れると、形は整いますが意味が変わります。空欄のまま出すか、項目ごと書かないかを選ぶほうが正確でした。

    そして、間隔は相手の都合で決めること。購読アプリが1時間おきにしか来ないなら、こちらが5分ごとに作り直しても誰も早く受け取れません。速くする対象を選ぶのも、相手が誰かを見てからでした。

  • 細かく刻んでも直らなかった、経路を変えたら一度で通った

    細かく刻んでも直らなかった、経路を変えたら一度で通った

    記事のアイキャッチ画像を、CMSへ入れる作業をしていました。画像を文字列に変換して、少しずつ送り込む方法です。

    ところが、ときどき壊れます。送った文字列と、届いた文字列が違う。

    対処として分割をどんどん細かくしていきました。7000字で壊れたので2500字にし、それでも壊れたので1750字にしました。1750字なら通る。通ったのですが、1枚の画像を送るのに17回かかる計算になりました。

    この記事で扱うこと

    失敗する手順を細かく刻んで通した話と、そのあと経路そのものを変えたら一度で終わった話です。同じ作業が66回から1回になりました。

    細かくすれば通る、までは正しかった

    画像をそのまま送れないので、いったん文字の並びに変換します。変換すると元の1.3倍くらいの長さになり、1枚で2万8千字ほどになりました。

    これを一度に送ると壊れます。どこで壊れたのかを知るために、分割するたびに短い検査用の値を一緒に送って、届いた側で計算し直して突き合わせました。合わなければその回だけやり直せます。

    1回あたりの長さ 結果
    7000字 不一致
    2500字 不一致
    1750字 4回連続で一致

    1750字なら安定して通ります。これで「送れる方法」は確立しました。

    安定はしたが、量が減ったわけではない

    1枚17回。今回は4枚必要だったので、66回の往復になります。

    しかも毎回、検査用の値を計算して突き合わせます。1回でも合わなければ、その回をやり直します。作業として成立してはいますが、成立させるために手間が積み上がっている状態でした。

    細かくするのは、経路を疑っていない証拠だった

    ここで手が止まりました。1750字という数字に、根拠が無いことに気づいたからです。

    7000で駄目、2500で駄目、1750で通った。通ったという事実しかありません。なぜ1750なら大丈夫なのかは説明できていません。次に別の環境で同じことをしたら、また試して探すことになります。

    そして、そもそもの前提を疑っていませんでした。「この経路で送る」ことは決まったこととして扱い、その中でどう工夫するかだけを考えていました。

    ファイルをファイルのまま渡す

    やり直したのは、経路の側です。

    文字列に変換して少しずつ流し込むのではなく、画像ファイルをそのままCMSのアップロード欄へ渡しました。ブラウザがファイルを受け取る仕組みは元からあり、CMSにもファイルを受け取る画面が元からあります。使っていなかっただけです。

    文字列に変換して送る ファイルのまま渡す
    やり取りの回数 66回 1回
    送る量 元の約1.3倍 元のまま
    壊れたときの検知 自分で検査値を付ける 仕組みの側が持っている
    画像の劣化 途中で形式を変換していた 無し

    4枚まとめて、約1.9MBを一度で渡して終わりました。分割そのものが不要になったので、検査値の計算も、やり直しの判断も全部消えました。

    工夫が消えるのは、良い兆候

    1750字という調整も、検査値を突き合わせる仕組みも、それ自体は正しく動いていました。役目を終えて消えたのではなく、最初から必要の無い経路を選んでいたということです。

    手順の途中に自作の工夫が積み上がってきたら、それは経路を疑う合図でした。

    細かく刻む対処が要るときもある

    もちろん、分割が悪いわけではありません。

    経路がひとつしか無いなら、その中で安定する大きさを探すのは正しい対処です。今回もそれで一度は通しました。壊れたことに気づけるようにした部分は、経路を変えたあとも考え方として残ります。

    問題は、そこで止まったことです。「通す方法が見つかった」時点で満足してしまい、「そもそもこの経路でよかったのか」を考えませんでした。

    今回の学び

    まず、通った理由を説明できない手順は、まだ解けていないということ。1750字で通ったのは事実ですが、なぜ通るのかは分かっていません。説明できない数字が手順に残っているとき、それは運用でごまかしている合図でした。

    次に、工夫が積み上がるほど、前提を疑いにくくなること。分割の仕組みと検査の仕組みを自分で作った後だと、その経路を捨てる判断はしにくくなります。手間をかけたものほど、使い続ける理由になってしまいます。

    そして、「どうやってうまくやるか」の前に「この道でいいのか」を1回置くこと。今回は66回を1回にしました。差が出たのは工夫の質ではなく、経路の選び方でした。

  • ページを消すとき、一緒に消えるもの

    ページを消すとき、一緒に消えるもの

    実績とスキルの個別ページを、トップページの中へ統合しました。

    作業自体は「セクションにidを付けて、ページを2枚消す」だけです。消すほうに、決めることが集まっていました。

    この記事で扱うこと

    個別ページを持つ価値をどう判断したか、ページを消すときに一緒に消えてしまうもの、そして統合したあとに出た「リンクを押しても動かない」問題です。

    同じ内容が、2つのURLにあった

    トップページには、もともと実績・スキル・プロフィールのセクションがあります。そのうえで /projects と /skills という個別ページも持っていました。

    個別ページを持つ価値は、そこにしかない情報があるかで決まります。実績が20件あって絞り込みや並べ替えが要るなら、独立したページに意味があります。

    しかし実績は数件で、当面増える見込みもありません。結果として、ほぼ同じ内容が2か所にある状態でした。

    同じ内容が2つあると、選ばせることになる

    検索エンジンから見ると、似た内容のページが2つあります。どちらを検索結果に出すべきかを判断させることになり、評価も2つに分かれます。

    読む人にとっても、メニューの「実績」を押した先が、直前まで見ていたトップの内容とほぼ同じ、というのは肩透かしです。

    消すと、一緒に消えるもの

    ここが本題です。ページを削除すると、そのURLは404になります。失われるのは中身だけではありません。

    URLに付いていたもの 削除したとき
    検索エンジンからの評価 引き継がれず消える
    他サイトから貼られたリンク 404へ向かう
    ブックマーク・履歴 404が出る

    URLは外から指される名前です。中を整理した都合で、外からの参照を無効にしてよい理由にはなりません。

    そこで、旧URLを新しい位置へ転送する設定を入れました。

    転送の設定
    /projects  →  /#projects
    /skills    →  /#skills

    これで評価は転送先へ引き継がれ、古いリンクを踏んだ人も目的の場所に着きます。

    恒久か、一時的か

    転送には「もう戻らない」と「一時的に別の場所を見せている」の2種類があります。今回は戻す予定が無いので恒久を選びました。

    恒久転送は、ブラウザが覚える

    恒久として返すと、ブラウザはその対応を記憶します。次からはサーバーに聞かずに転送先へ行きます。速い代わりに、あとで気が変わっても、記憶を持っている人には届きません。

    迷いがあるうちは一時的な転送にしておくほうが安全です。恒久にするのは、本当に戻さないと決めてからで遅くありません。

    sitemapからは外した

    細かい点ですが、転送するURLを一覧に載せたままにしませんでした。

    sitemapは「このURLを登録してほしい」という一覧です。そこに転送するURLを書くと、「登録してほしい」と「そこへ行くな」を同時に伝えることになります。矛盾した指示は、送らないほうが良いはずです。

    統合したら、リンクが動かなくなった

    ここで問題が出ました。トップページ以外から「実績」を押しても、目的の場所へ行きません。

    原因は、ページの移動の仕方でした。

    同じ「#つきリンク」でも、起きることが違う
    同じページの中     → ブラウザが自分でスクロールする
    別のページから来た → 画面を読み込み直さず中身だけ差し替える
                         = ブラウザは「移動した」と思っていない
                         = URLの # を見て動いてくれない

    加えて、移動した直後は画面の先頭に戻されます。何もしないと「トップページの一番上に着いただけ」という結果になります。

    そこで、移動後にURLの # を読んで、その位置までスクロールさせる処理を足しました。飛び先がまだ描画されていない場合に備えて、1回だけ待って再試行しています。

    仕組みが変わると、当たり前が当たり前でなくなる

    # つきのリンクは、HTMLの最初期からある機能です。動いて当然と思っていました。

    しかし「画面を読み込み直さずに中身を差し替える」作りにした時点で、ブラウザが担当していた仕事の一部を、こちら側が引き取っています。引き取ったことに気づいていないと、こういう抜けになります。

    着地の位置も直した

    もうひとつ。アンカーで飛んだ先が、貼り付いたヘッダーの裏に隠れていました。

    飛び先の位置を計算するとき、ブラウザはヘッダーの存在を考慮しません。要素の上端を画面の上端に合わせるだけです。

    飛んだときだけ余白を確保する指定があるので、ヘッダーの高さぶんを足しました。

    今回の学び

    まず、個別ページを持つ価値は、そこにしかない情報があるかで決まること。ページ数が多いこと自体には価値がありません。同じ内容が複数の場所にあると、評価も注意も分散します。

    次に、URLは外から指される名前なので、中の都合で無効にしないこと。これはアンカーIDのときと同じ話でした。指す側は、指される側の事情を知りません。消すなら、行き先を用意してから消します。

    そして、恒久的な指定は、取り消しにくいこと。恒久転送はブラウザに記憶されるので、あとから変えても届かない相手が出ます。迷いがあるうちは一時的にしておく判断があります。

    最後に、仕組みを変えたら、ブラウザ任せだった部分を確認すること。画面を読み込み直さない作りにした時点で、いくつかの仕事がこちら側に移っています。移ったことに気づかないまま「昔から動くもの」として扱うと、静かに動かなくなります。

  • ゆっくり読む人ほど、読了にならなかった

    ゆっくり読む人ほど、読了にならなかった

    「ゆっくり読むと、読了にならないのではないか」。自分で読み返していて、そう思いました。

    調べたところ、そのとおりでした。丁寧に読む人ほど読了にならず、勢いよくスクロールした人ほど読了になるという、逆の作りになっていました。

    この記事で扱うこと

    割合で測るときに何を分母にするかで意味が変わるという話と、「最後まで到達した」と「読み終えた」を分けて考えた結果です。

    元の判定

    読了は、こう判定していました。

    元の計算
    進捗 = スクロール位置 ÷ (ページ全体の高さ − 画面の高さ)
    
    進捗が 95% を超えたら読了

    一見、妥当に見えます。95%にしているのも、末尾ぴったりまで行かなくてよいようにという配慮でした。

    実際に測ってみた

    推測で直すと、原因が別にあったときに気づけません。実際の記事のページで数字を出しました。

    測ったもの 値
    ページ全体の高さ 7,609px
    本文の高さ 6,153px
    本文より下に残っている高さ 684px
    本文を読み終えた時点の進捗 89.9%

    本文を最後の1行まで読んでも、89.9%にしかなりません。95%に届くには、そこからさらに344px下——前後の記事へのリンクと、フッターの中まで進む必要がありました。

    なぜ「ゆっくり」だと届かないのか

    ここが今回の肝でした。読む速さと、スクロールの止まる位置には関係があります。

    同じページでも、止まる場所が違う
    勢いよくスクロールした人
      → 慣性でページの最下端まで滑る → 100% → 読了
    
    文章を追って読んだ人
      → 本文の最後の行で手を止める   → 89.9% → 読了にならない

    速く動かした指は、そのまま惰性でフッターまで運ばれます。一方、読んでいる人は読むものが無くなった時点で止まります。当然の行動です。

    分母に、読まないものが入っていた

    ページ全体の高さには、フッターも、前後の記事へのリンクも、一覧へ戻るリンクも含まれます。どれも「読む」対象ではありません。

    測りたかったのは「本文をどこまで読んだか」なのに、分母は「ページをどこまで動かしたか」でした。名前は進捗でも、中身はスクロール量です。

    分母を本文に変えた

    基準を、ページ全体から本文の高さに変えました。

    新しい計算
    進捗 = (画面の下端 − 本文の開始位置) ÷ 本文の高さ

    本文の末尾が画面の下端に来た時点で100%になります。読み終えたら100%という、当たり前の対応になりました。

    ただし、これだけでは足りない

    末尾への到達だけを条件にすると、別の問題が残ります。目次から末尾の見出しへ飛んだ場合も、一気に下まで送った場合も、読了になります。

    「画面が下まで移動した」ことと「読み終えた」ことは、別のできごとです。

    そこで条件を2つにしました。

    条件 これが無いと
    本文の末尾まで到達している 開いて放置しただけで読了になる
    一定時間、実際に見ている 下まで送っただけで読了になる

    時間の基準は読了目安の4分の1にしました。8分の記事なら2分です。速く読む人を弾かない程度に緩く、しかし一瞬で下まで送った操作は通らない、という線を狙っています。

    「開いていた時間」ではなく「見ていた時間」

    単純に開いてからの経過時間で測ると、タブを開いたまま放置した時間まで読書時間になります。それでは条件の意味がありません。

    ブラウザには「このページがいま見えているか」を知る手段があります。見えている間だけ加算するようにしました。席を外して戻ってきた人が損をしません。

    一度到達したことを、忘れないようにする

    実装中に、条件が2つになったことで生まれる穴に気づきました。

    2つの条件は、同時に満たされるとは限りません。先に末尾まで読み、そのあと上に戻って読み返している最中に時間の条件を満たすことがあります。

    このとき現在位置だけを見ていると、「いま末尾にいないので読了ではない」と判定されます。一度は最後まで読んだという事実が、位置を戻した瞬間に消えてしまいます。

    起きたことは、消さずに覚えておく

    到達はできごとで、位置は状態です。できごとを状態から読み取ろうとすると、状態が変わったときに失われます。

    「一度でも到達したか」を別に記録するようにしました。読み返しても消えません。

    高さが変わることも見るようにした

    もうひとつ。本文の高さは、読んでいる最中にも変わります。画像が後から読み込まれる、目次を開く、画面を回す。

    元の実装は画面サイズが変わったときしか計算し直していませんでした。画像の読み込みでは、その通知は来ません。

    要素の大きさが変わったこと自体を教えてくれる仕組みに変えました。高さがずれたまま古い値で判定し続けることが無くなります。

    表示のほうも、記録に合わせた

    最後に、細かいけれど気持ち悪い食い違いを直しました。

    画面の「読了済み」という表示は、残り0%になった時点で出ていました。しかし実際に記録されるかどうかは、時間の条件も見ています。

    つまり画面には読了済みと出ているのに、記事一覧には読了マークが付かない状態があり得ました。

    表示のほうを、記録に従わせるようにしました。2つの真実を持たない、というだけの話です。

    今回の学び

    まず、割合で測るなら、分母が何かを疑うこと。今回は分母に、読む対象でないものが1割ぶん入っていました。式は正しく動いていて、測っているものが違っただけです。正しく計算された、意味の違う数字がいちばん見つけにくいと思います。

    次に、推測で直さず、まず測ること。「95%は厳しすぎるから90%に下げよう」でも症状は消えたはずです。しかしそれは、フッターの高さが変わるたびに再発します。89.9%という数字を出したから、分母の問題だと分かりました。

    そして、操作と、意味を区別すること。「下までスクロールした」は操作で、「読み終えた」は意味です。操作から意味を推し量るときは、必ずずれが出ます。ずれを埋める条件が要ります。

    最後に、できごとは、状態から読み取らないこと。一度起きたことは、現在の状態を見ても分からなくなることがあります。起きた時点で記録しておけば、あとから位置が変わっても失われません。

  • 長さが合っているのに、中身が壊れていた

    長さが合っているのに、中身が壊れていた

    作った画像を、ブラウザ越しにCMSへ上げる必要がありました。一度に送れない大きさだったので、分割して送り、向こう側でつなぎ直しています。

    つなぎ直した長さは、元のデータと1文字も違いませんでした。それでも上がった画像は真っ黒でした。

    この記事で扱うこと

    長さが合っているのに中身が壊れていた原因と、それを見つけるまでに遠回りしたこと。そして分割して送るときに、検証をどこに置くべきかです。

    長さが合うことは、無事の証明にならない

    最初に確認したのは長さでした。送った文字数と受け取った文字数が一致していたので、無事に届いたと判断しました。

    これが間違いです。どこかで1文字増えて、どこかで1文字減れば、長さは変わりません。実際に起きていたのはこれでした。

    確かめやすいことを、確かめたいことの代わりにしていた

    確かめたかったのは「中身が同じか」です。確かめたのは「長さが同じか」でした。長さは数えるだけで済むので、つい代わりに使ってしまいました。

    長さが違えば壊れていると分かりますが、長さが同じでも壊れていないとは言えません。片方向にしか使えない検査を、両方向に使っていました。

    どこで壊れたのかが分からない

    次に困ったのが、壊れた場所の特定です。

    全体をひとつの塊として扱っていたので、「どこかが壊れている」以上のことが分かりません。やり直すとしたら全体をやり直すしかなく、しかも同じ場所でまた壊れるかどうかは運任せになります。

    実際、一度送り直しても同じように壊れました。何が起きているか分からないまま、同じ操作を繰り返していました。

    分割して送るなら、検証も分割する

    やり方を変えました。送る側で分割したときに、各かたまりごとに短い検査値を計算して一緒に渡します。受け取る側は、つなぐ前にその場で照合します。

    全体だけを検証 かたまりごとに検証
    壊れたことに気づける すべて送り終えたあと そのかたまりを受け取った時点
    壊れた場所 分からない そのかたまりだと確定する
    やり直す範囲 全体 そのかたまりだけ

    これに変えた途端、壊れている場所が一発で出ました。しかも受け取り側が拒否を返すので、壊れたまま先へ進むことがなくなります。

    やり直しの範囲は、検証の粒度で決まる

    検証を全体にしか置かないと、失敗も全体になります。細かく検証すれば、失敗も細かくなります。

    分割する目的は「一度に送れないから」でしたが、分割したことの利点は、失敗を小さくできることのほうにありました。

    検証は、転送の「あと」ではなく「一部」にする

    ここが今回の考え方の変化でした。

    最初は、送り終えてから確かめる、という順番で組んでいました。転送と検証が別の工程になっていて、間に「壊れたまま進む余地」があります。

    いまは、検査値が合わないかたまりは受け取らない形にしています。検証は転送の後工程ではなく、転送の一部です。こうすると「受け取れた」と「正しい」が同じ意味になります。

    形式として正しくても、中身が正しいとは限らない

    もうひとつ、別の層の話があります。

    検査値が合って、データが完全に届いた。そこまで確認しても、上がった画像が意図したものかは、まだ分かりません。実際、最初に壊れた画像は「画像ファイルとしては正しい形式」で、開くこともできました。中身が真っ黒だっただけです。

    そこで、送り先に登録する前に、実際に画素を読むようにしました。画面には出さず、いくつかの座標の色を取って、想定した配色になっているかを見ます。真っ黒なら、そこで止まります。

    出す前に、出すものを見る

    「形式として妥当か」と「意味として正しいか」は別の検査です。前者は自動で分かりますが、後者は中身を見に行かないと分かりません。

    面倒に見えますが、やっているのは数点の色を読むだけです。壊れたものを世に出してから気づくコストと比べれば、はるかに安く済みます。

    今回の学び

    まず、長さの一致は、内容の一致ではないこと。確かめやすい指標を、確かめたい事実の代わりに使うと、一致しているのに壊れている状態を見逃します。

    次に、失敗の大きさは、検証をどの粒度に置いたかで決まること。全体にしか検証を置かなければ、やり直しも全体になり、原因も特定できません。分けて送るなら、分けて確かめるべきでした。

    そして、検証は工程の後ろに置くと、壊れたまま進む余地が残ること。転送の一部にしてしまえば、受け取れたことがそのまま正しさの証明になります。順番を変えただけで、確認という作業そのものが要らなくなりました。

    最後に、形式の正しさと意味の正しさは、別々に確かめる必要があること。壊れた画像は画像として開けました。開けることと、意図したものであることは、まったく別の話でした。

  • 測れるもので代用すると、測りたいものから離れる

    測れるもので代用すると、測りたいものから離れる

    「記事を最後まで読んだ人が何人いるか」を知りたい、というだけの話でした。単純に見えたので、すぐ作れると思っていました。

    実際には4回作り直しています。そのたびに別の壊れ方をしました。

    この記事で扱うこと

    読了の判定を作り直した4回ぶんの経緯と、そのたびに何を見落としていたかです。最後に、そもそも何を測ろうとしていたのかを整理します。

    1回目:下までスクロールしたかで測った

    最初は素直に、ページの何%までスクロールしたかで判定しました。95%まで来たら読了、という形です。

    これがうまくいきませんでした。分母にしていたページ全体の高さには、本文の下にある前後記事のリンクや一覧へ戻るリンク、フッターまで含まれています。実測すると、その下側だけで約680pxありました。

    分母に、読むものではないものが入っていた

    本文を最後まで読み終えても、進捗は90%前後にしかなりません。100%にするには、読むもののないフッターまでスクロールする必要がありました。

    勢いよくスクロールする人は慣性で最下端まで行くので満たせます。一方、文章を追って読む人は本文の終わりで手を止めます。ゆっくり読む人ほど読了にならないという、目的と逆の結果になっていました。

    2回目:本文の高さを分母にした

    分母を本文だけに変えました。本文の末尾が画面の下端に来た時点で100%になります。読み終えれば100%、という当たり前の対応になりました。

    ただ、これで別の穴が開きます。目次から末尾へ飛んだ場合も100%になります。位置だけを見ている限り、そこに至った経緯は分かりません。

    3回目:滞在時間の条件を足した

    そこで「そのページを実際に見ていた時間が一定以上ある」を条件に加えました。一瞬で駆け抜けた操作を弾くためです。

    条件が2つになった瞬間、画面の表示と記録がずれ始めました。

    回 何で判定したか そこで抜けたもの
    1 ページ全体のスクロール率 読む人ほど到達しない
    2 本文のスクロール率 飛んだ人も通る
    3 本文の到達+滞在時間 表示と記録がずれる
    4 同上(時間を緩和) 読まずに時間だけで通る

    進捗のバーは位置だけを表しているので、末尾に着いた瞬間に満杯になります。しかし記録は時間の条件を満たすまで行われません。バーは終わっているのに、読了済みにならないという食い違いが画面に残りました。

    条件を足すと、それを表す場所も増える

    判定に条件を足すのは簡単ですが、画面に出している表示のほうは1つのままでした。

    1つの表示で2つの条件を表そうとすると、必ずどちらかの状態を隠すことになります。隠された側が読み手には「動かない」ように見えます。

    間に挟まった、数字の問題

    ずれを隠そうとして、記録される前は「あと1%」で止める、という処理を入れました。0%と出しているのに読了済みに変わらない、という矛盾を避けるためです。

    これは失敗でした。今度は「1%のまま動かない」という別の分かりにくさになりました。残っていないものを数字で表そうとしたのが、そもそもの間違いでした。

    数字で表せない状態は、言葉で書く

    「残り」は数字で表せますが、「末尾には着いたが、まだ記録されていない」は数字ではありません。

    ここは数字をやめて、状態をそのまま言葉にするべき場所でした。無理に数値化すると、動かない数字という嘘が残ります。

    4回目:今度は、読まずに完了するようになった

    時間の条件を緩めたところ、逆の症状が出ました。読み終えていないのに、時間の経過だけで完了になります。

    設計上、位置と時間の両方を満たさないと読了にはならないはずです。それでも時間だけで通るということは、位置の条件が常に成立しているとしか考えられません。

    条件が2つあると、片方が壊れたときに気づけない

    2つの条件を「かつ」で結ぶと、片方が常に真になっても、全体としては動き続けます。残った条件が単独の判定として働くだけで、エラーにはなりません。

    気づけたのは、症状が「時間だけで通る」という形で表に出たからです。出方が違えば、そのまま気づかずにいた可能性があります。

    そもそも、測れるもので代用していた

    ここまで来て、やっと本題に気づきました。

    知りたいのは「読んだか」です。しかし測れるのは「画面がそこを通ったか」と「そのページが開かれていた時間」だけです。読んだかどうかは、ブラウザからは直接見えません。

    つまり最初から、測りたいものを測れるもので代用していました。代用そのものは避けられません。問題は、代用していることを忘れて、条件を足したり引いたりしていたことです。

    代用だと分かっていれば、条件の足し方が変わる

    条件を足すたびに「これは読んだことの何を近似しているのか」を書いていれば、位置と時間が別々の近似だと分かったはずです。

    別々の近似を「かつ」で結べば、片方が外れたときに残りが単独で判定を下します。足したのは精度ではなく、壊れ方の種類でした。

    いま考えている形

    次は、時間をやめて経路を見ようと思っています。

    記事をいくつかの区画に区切り、そのすべてが画面を通ったかどうかを判定します。目次から末尾へ飛べば途中の区画を通らないので読了になりません。ゆっくり読む人は必ず全区画を通るので、確実に読了になります。

    時間に頼らずに「飛ばした人を通さない」と「速く読む人を弾かない」の両方が満たせます。何より、近似しているものが1種類になるので、片方が壊れて気づかない、という形にはなりません。

    今回の学び

    まず、測りたいものと測れるものは違うこと。読んだかどうかは見えないので、必ず何かで代用します。代用だと自覚していないと、精度を上げているつもりで壊れ方を増やすことになります。

    次に、条件を「かつ」で結ぶと、壊れても動き続けること。片方が常に真になっても例外は出ません。残った条件がそのまま判定として働くので、症状が出るまで気づけません。条件を足すときは、それぞれが単独になったときに何が起きるかを見ておく必要がありました。

    そして、判定に条件を足したら、表示も足すか、表示のほうを言い換えること。1つの表示で2つの状態を表そうとすると、必ずどちらかが隠れます。隠れた側は、読み手には「壊れている」ように見えます。

  • 順番で決まる名前は、いつか壊れる

    順番で決まる名前は、いつか壊れる

    用語集の102語と、記事のあいだにリンクを張ろうとしました。

    ところが張る前の下調べで、そのリンクが最初から壊れる仕組みになっていることが分かりました。順番を変えただけで壊れます。

    この記事で扱うこと

    並び順から自動でIDを作ることの危うさ、公開前の記事へリンクしてしまっていた話、そして「直したつもり」で終わらせないための確認方法です。

    用語ページの中身を、直接指せるようにしてある

    用語集は分野ごとに1ページで、1ページに10〜18語ほど入っています。個々の語は見出しに id を振ってあり、こう書けば特定の語へ直接飛べます。

    語を直接指す
    /glossary/security/#waf

    記事から用語へリンクするには、この id が要ります。そこで中身を確認しました。

    半分以上が、意味のない名前だった

    IDは用語名から機械的に作っていました。英数字だけを残し、それ以外を捨てる方式です。

    用語 できたID
    WAF waf
    DNS dns
    ネームサーバー term
    名前解決 term-2
    サブドメイン term-3

    日本語の語は、英数字を残すと何も残りません。空になったものには、衝突しないように連番を振っていました。

    102語のうち55語がこの状態でした。

    なぜ、これが壊れるのか

    問題は読みにくさではありません。この連番が「何番目に出てきたか」で決まることです。

    用語を1語追加すると
    追加前                    追加後
    term    ネームサーバー     term    ネームサーバー
    term-2  名前解決           term-2  【ここに1語追加】  ← 割り込む
    term-3  サブドメイン       term-3  名前解決           ← ずれた
                              term-4  サブドメイン       ← ずれた

    用語を1語足すと、それ以降のIDが全部1つずつずれます。

    そして、ずれてもエラーは出ません。ページは正常に表示されます。リンクを踏んだ人が、ページの先頭に着地して「あれ?」と思うだけです。

    外から指される名前は、中の都合で変えてはいけない

    IDは、他の場所から指すための名前です。指す側は、指される側の並び順を知りません。

    用語集を充実させるほど、つまり良い方向に手を入れるほど、リンクが壊れていく。更新すると壊れる仕組みを先に作ってしまっていました。

    手で書いて、増えないようにした

    自動生成をやめ、102語すべてに固定のIDを手で割り当てました。

    対応表を1枚用意する
    "ネームサーバー": "nameserver",
    "名前解決":       "name-resolution",
    "サブドメイン":   "subdomain",

    手で書くのは面倒に見えますが、面倒なのは最初の1回だけです。追加する語は1行足すだけで、既存のIDは動きません。

    あわせて、対応表に無い語が来たら止まるようにしました。

    黙って埋めるより、止まったほうがいい

    未登録の語に自動で連番を振る作りだと、また同じ問題が静かに戻ってきます。止まれば、その場で1行足すだけで済みます。

    「気を付ける」で運用するより、間違えられない形にしておくほうが確実です。

    もうひとつ、公開前の記事へリンクしていた

    用語集には「関連記事」を載せています。ここも確認したところ、22件のうち8件が、まだ公開していない記事を指していました。

    用語集のページは公開済みです。つまり読者から見ると、リンクを踏むと404になります。

    原因は単純で、記事を書く前提でリンクを先に書いていたためです。

    そこで、公開済みの記事の一覧を持たせ、そこに載っていないリンクは出力しないようにしました。記事を公開したときに一覧へ1行足せば、リンクも一緒に出てきます。

    ついでに、リンクの文字をスラッグから記事タイトルに変えました。apex-vs-www と書かれても、押す前に中身が分かりません。

    直したあと、本当に直ったかを確かめる

    ここが今回いちばん大事な部分です。

    本番のページを書き換える必要がありましたが、「更新しました」という応答は、内容が正しいことを意味しません。途中で一部だけ置き換わっていても、成功として返ってきます。

    そこで、手元で作ったHTMLと、本番に入ったHTMLを数値に変換して突き合わせました。

    確認の流れ
    手元で組み立てたHTML   → 数値に変換 → A
    本番に入ったHTML       → 数値に変換 → B
    
    A と B が一致 → 1文字も違わない
    

    文章を1文字ずつ見比べるのは現実的ではありません。全体を1つの数値に潰してしまえば、一致するかどうかだけを見れば済みます。

    8ページすべてで一致しました。さらに、書き換えたあとで全リンクの飛び先が実在するかも機械的に確認しています。

    確認したこと 件数 壊れ
    記事 → 用語集のリンク 230 0
    用語集 → 記事のリンク 38 0
    下書きのまま残っているか 10件すべて —
    確認まで自動にしておく

    268本のリンクを手で踏んで確かめることはできません。目視で確認できない量になった時点で、確認する仕組みのほうを用意する必要があります。

    「たぶん大丈夫」と「1件も壊れていないことを確かめた」は、別のことです。

    今回の学び

    まず、外から指される名前は、中の都合で変わってはいけないこと。並び順・件数・登録した順番といった内部の事情でIDが決まると、中を整理するたびに外が壊れます。名前は約束なので、勝手に変えられない形にしておくべきです。

    次に、自動生成は、入力の性質を確かめてから使うこと。英数字を残す処理そのものは間違っていません。日本語の用語に対して使ったことが間違いでした。半分以上で空になると分かっていれば、最初から別の方法を選んでいたはずです。

    そして、足りないときは黙って埋めずに止めること。埋めてくれる親切な仕組みは、間違いも一緒に埋めます。止まれば気づけます。

    最後に、直したことと、直ったことは違うこと。更新が成功したという応答は、内容が正しい証明にはなりません。手元の正解と本番を突き合わせて初めて、直ったと言えます。この確認自体が数秒で終わるなら、やらない理由はありません。

  • よく使うGitコマンドと、それが何をしているか

    よく使うGitコマンドと、それが何をしているか

    Gitのコマンドは、覚えるより「いま何がどこにあるか」を掴むほうが早いです。

    この記事では、実際に毎日使っているコマンドを、何をしているかの説明つきで並べます。順番は使う頻度ではなく、理解の順番にしました。

    この記事で扱うこと

    Gitが管理している3つの場所という考え方と、それに沿ったコマンドの整理。特に「戻す」系は似たコマンドが多く、取り違えると作業が消えます。実際に消した経験も含めて書きます。

    まず、3つの場所を知る

    Gitのコマンドが分かりにくいのは、ファイルが3つの場所に同時に存在するからです。ここを掴むと、大半のコマンドは「どこからどこへ動かすか」で説明がつきます。

    場所 何があるか 失うとどうなるか
    作業ツリー いまエディタで見ているファイル 戻せない
    ステージ 次のコミットに含めると決めたもの 作業ツリーから入れ直せる
    履歴 コミット済みのもの だいたい取り戻せる

    右の列が重要です。コミットしていないものだけが、本当に消えます。逆に言えば、コミットさえしてあれば、たいていの失敗は取り返せます。

    流れ
    作業ツリー  --git add-->  ステージ  --git commit-->  履歴
       (編集中)              (準備中)           (記録済み)

    状態を見る

    作業の8割はこれです。何かする前に、いまどうなっているかを見ます。

    git status –short
     M frontend/src/app/page.tsx      ← 変更あり(未ステージ)
    M  frontend/src/lib/data.ts       ← 変更あり(ステージ済み)
    ?? frontend/src/lib/new-file.ts   ← 新規(Gitがまだ知らない)

    左の2文字が、ステージと作業ツリーの状態を表します。--short を付けると1ファイル1行になり、ファイル数を数えられます。これが後で効きます。

    git log –oneline -5
    c5ce23e Merge pull request #11 from kisaku39/feat/next-16
    29aa412 chore(deps): Next.js 16 / React 19 へ移行
    ba625e3 Merge pull request #10 from ...

    直近5件の履歴。--oneline が無いと1コミットが5行になり、画面が埋まります。

    変更内容を見る
    git diff              # 作業ツリーの変更(未ステージ)
    git diff --staged     # ステージ済みの変更
    git diff --stat       # ファイル名と増減行数だけ

    --stat は、変更が意図した範囲に収まっているかを一目で見るときに使います。

    記録する

    基本の3つ
    git add ファイル名        # 指定したものをステージへ
    git add -A               # 変更・新規・削除をすべてステージへ
    git commit -m "説明"      # ステージの中身を履歴へ記録

    git add -A は便利ですが、意図しないファイルまで巻き込みます。実行前に git status --short で並んでいるものを確認する習慣にしておくと安全です。

    ファイル数を数える

    私は一度、5ファイル直したはずのコミットに2ファイルしか入っていない状態でpushしました。ビルドも通り、デプロイも成功し、何も直っていませんでした。git status --short の行数を数えるだけで、pushする前に気づけた失敗です。

    直前のコミットをやり直す
    git commit --amend -m "新しい説明"

    直前のコミットを作り直します。メッセージの誤字直しや、入れ忘れたファイルの追加に使います。push済みのコミットには使わないでください。履歴が別物になり、他の人の手元と食い違います。

    戻す ── ここが一番危ない

    似た名前のコマンドが複数あり、取り違えると作業が消えます。用途で分けて覚えるのが安全です。

    やりたいこと コマンド 危険度
    ステージから降ろす(編集は残す) git restore --staged ファイル 安全
    ファイルの編集を捨てる git restore ファイル 戻せない
    コミットを打ち消す新しいコミットを作る git revert コミットID 安全
    履歴を巻き戻す(編集は残す) git reset --soft コミットID 注意
    履歴を巻き戻して全部捨てる git reset --hard 非常に危険

    reset は「取り消し」ではなく「破棄」

    git reset --hard は、直前の操作を取り消したいときに手が伸びやすいコマンドです。しかし実際にやることは違います。

    git reset –hard が消すもの
    コミット済みの変更   → 履歴に残っているので取り戻せる
    未コミットの編集     → 消える。取り戻す手段は無い

    履歴に無いものは、履歴から復元できません。当たり前ですが、この当たり前が事故の形になります。私はこれで、書き換えたばかりの5ファイルを失いました。

    状態を整えたいだけなら stash

    「一旦きれいにしたい」「ブランチを切り替えたい」という理由でresetを使いたくなったら、その用途は git stash のほうです。捨てずに退避してくれます。

    revert と reset の違い

    どちらも「戻す」ですが、履歴の扱いが逆です。

    同じ「戻す」でも
    reset  … 履歴そのものを巻き戻す(無かったことにする)
    revert … 「打ち消す」コミットを新しく積む(戻した記録が残る)

    すでにpushしたものを戻すときは、必ずrevertです。resetで消すと、他の人の手元にある履歴と食い違い、収拾がつかなくなります。

    退避する

    git stash
    git stash              # 変更を退避して、作業ツリーをきれいにする
    git stash -u           # 未追跡(新規)ファイルも一緒に退避
    git stash list         # 退避したものの一覧
    git stash pop          # 最後に退避したものを戻す
    git stash drop         # 退避したものを捨てる

    -u を付ける場面が意外と多いです。既定のstashは、Gitがまだ知らない新規ファイルを退避しません。そのままpullすると「未追跡のファイルを上書きすることになるので中断する」と止められます。

    stash は名前を付けられる

    git stash push -m "pull前の退避" のようにメモを残せます。stashは溜まると何が何だか分からなくなるので、いつ・なぜ退避したかを書いておくと後で助かります。

    ブランチを扱う

    よく使うもの
    git branch                    # ブランチ一覧(* が現在地)
    git branch --show-current     # 現在のブランチ名だけ
    git checkout -b feat/xxx      # 新しいブランチを作って移動
    git checkout main             # 既存のブランチへ移動
    git branch -d feat/xxx        # ブランチを削除

    git branch -d はマージ済みのブランチしか消しません。未マージの変更が残っていれば拒否してくれます。大文字の -D は確認なしで消すので、日常では使わないほうが安全です。

    なぜブランチを切るのか

    本番に直接入れると、壊れていた場合に「戻す」しか選択肢がありません。ブランチで作業すれば、本番を触らないまま動作確認できます。デプロイ先によっては、ブランチごとに確認用のURLを作ってくれます。確認の機会を1回増やすだけで、事故の大きさが変わります。

    共有する

    やり取りの3つ
    git fetch      # リモートの状態を取ってくる(作業ツリーは変えない)
    git pull       # fetch + 自分のブランチに取り込む
    git push       # 自分のコミットをリモートへ送る

    git push が拒否されることがあります。

    よくある拒否
    ! [rejected]  main -> main (non-fast-forward)
    hint: Updates were rejected because the tip of your current
    hint: branch is behind its remote counterpart.

    これは正しい動作です。手元がリモートより遅れている状態でpushすると、リモート側の新しいコミットが消えます。Gitはそれを防いでいます。

    対処は「先に取り込む」です。git pull してから、あらためてpushします。ここで --force を使うと、止めてくれた意味が無くなります。

    困ったときの2つ

    操作の履歴をたどる ── reflog

    git reflog
    c5ce23e HEAD@{0}: commit: ...
    682faf3 HEAD@{1}: reset: moving to HEAD    ← ここで何かした
    682faf3 HEAD@{2}: pull: Fast-forward

    git log がコミットの履歴なのに対し、reflog は「自分がGitに何をしたか」の履歴です。checkout、reset、pull、commit——すべて記録されています。

    「変更が消えた」「いつのまにか違うブランチにいる」というときは、まずこれを見ます。何が起きたかを推測せずに済みます。私も、消えた原因がresetだったことをこれで特定しました。

    「別のプロセスが動作中」と言われる ── index.lock

    こんなエラー
    fatal: Unable to create '.git/index.lock': File exists.
    Another git process seems to be running in this repository.

    Gitは操作のたびに .git/index.lock という目印のファイルを作り、終わったら消します。何らかの理由で消し損ねると、このファイルが残り続けます。

    そしてロックが残っている間、Gitはすべての操作を拒否します。本当に別のプロセスが動いている可能性があるからです。

    対処
    # 本当に他でGitを動かしていないことを確認してから
    rm -f .git/index.lock

    このファイルは0バイトで、中身がありません。Gitのデータは一切入っていない、単なる目印です。他でGitを動かしていないことさえ確かなら、消して問題ありません。

    同じリポジトリを複数の場所から触らない

    私がこれを踏んだのは、同じリポジトリに2つの経路から触っていたためでした。片方に書き込み権限が足りず、ロックを作れたのに消せませんでした。.git は排他制御を前提に作られているので、複数の経路から同時に触る構成は避けるべきです。

    1日の流れに並べると

    典型的な作業
    git status --short            # 汚れていないか確認
    git pull                      # 最新を取り込む
    git checkout -b feat/xxx      # 作業用のブランチへ
    
    (編集する)
    
    git status --short            # ファイル数を数える
    git diff                      # 中身を確認
    git add -A
    git commit -m "..."
    git push -u origin feat/xxx   # ブランチのままpush
    
    (確認用URLで動作確認)
    (問題なければマージ)
    
    git checkout main
    git pull
    git branch -d feat/xxx        # 役目を終えたブランチを削除

    この中でいちばん省かれやすいのが、最初と真ん中の git status --short です。そして今日書いた失敗は、どちらもここを飛ばしたことが原因でした。

    まとめ

    まず、コミットしていないものだけが本当に消えること。逆に言えば、こまめにコミットしておけば、たいていの失敗は取り返せます。危ない作業の前にコミットを1つ挟むのは、いちばん安いお守りです。

    次に、reset は取り消しではなく破棄だということ。状態を整えたいだけなら stash を使います。用途で選び分けるだけで、事故の大半は避けられます。

    そして、何かする前に状態を見ること。git status --short と git diff --stat の2つを挟むだけで、「意図しないものが混ざっている」「意図したものが入っていない」の両方に気づけます。

    最後に、止められたときは理由があること。pushの拒否も、ロックによる中断も、Gitが壊れないように守っている動作です。--force や強制削除で押し通す前に、なぜ止まったのかを読むほうが、結果的に速いです。

  • 通ったビルドが、何も直していなかった

    通ったビルドが、何も直していなかった

    ビルドが通った。テストも落ちていない。デプロイも成功した。

    それでも、直したかったものは1つも直っていませんでした。

    この記事で扱うこと

    変更が丸ごと消えた状態でコミットが成功してしまった話と、それがなぜ「成功」に見えたのか。そして、こういう失敗を見つけるための確認が「動くかどうか」ではなく「入っているかどうか」だという話です。

    やろうとしていたこと

    2つの修正を、1回の作業でまとめる予定でした。

    直したいもの 理由
    記事一覧の取得上限(20→100) 公開記事がちょうど20本。次の1本から漏れ始める
    構造化データの日時をGMTに 時差の情報が無い時刻を、機械に渡していた

    どちらもGraphQLのクエリを変える作業です。このプロジェクトはクエリから型を自動生成しているので、クエリを変えたら型を作り直す必要があります。作り直しにはCMSへの接続が要るため、手順はこうなりました。

    予定していた手順
    1. ソースを5ファイル書き換える
    2. npm run codegen   (型を作り直す)
    3. npm run build     (手元で通ることを確認)
    4. commit → push

    全部が「成功」した

    実行結果は、どこにも問題がありませんでした。

    codegenは通りました。ビルドも通りました。コミットもpushも成功し、Vercelのデプロイも緑になりました。本番サイトは変わらず表示されています。

    ところが本番の構造化データを見ると、日時が変わっていません。

    期待していたもの / 実際
    期待  "datePublished": "2026-08-22T04:20:00Z"
    実際  "datePublished": "2026-08-22T13:20:00"

    デプロイの遅れかと思い、キャッシュを避けて何度か取り直しました。変わりません。

    コミットの中身を見た

    ここでようやく、コミットに何が入っているかを確認しました。

    git show –stat
    frontend/src/gql/gql.ts     | 5 +++++
    frontend/src/gql/graphql.ts | 8 +++++++-
    2 files changed, 12 insertions(+), 1 deletion(-)

    2ファイル。しかも両方とも、自動生成されたファイルです。手で書き換えたはずの5ファイルが、1つも入っていません。

    作業ディレクトリを見ると、posts(first: 20) のまま戻っていました。書き換えたはずの内容が、どこにも残っていません。

    何が起きていたか

    git reflog に答えがありました。

    git reflog
    d370f37 HEAD@{0}: commit: fix: 記事一覧の上限を100に上げ...
    682faf3 HEAD@{1}: reset: moving to HEAD     ← これ
    682faf3 HEAD@{2}: pull: Fast-forward

    pullの直後、コミットの前に reset が挟まっています。git reset --hard は、コミットしていない変更を問答無用で消します。元に戻す手段はありません。

    つまり、書き換え → リセットで消滅 → その状態でcodegen → コミット、という順番で進んでいました。

    reset は「取り消し」ではなく「破棄」

    直前の操作を取り消したいときに git reset --hard を使いたくなりますが、これはコミット済みの履歴を戻す道具です。まだコミットしていない作業は、履歴に無いので戻せません。作業を残したまま状態を整えたいなら git stash のほうです。

    なぜ「成功」してしまったのか

    ここが今回いちばん考えさせられた点です。

    変更が消えているのに、なぜビルドが通ったのか。答えは、消えた結果として、全部が整合していたからです。

    クエリ側 生成された型 結果
    意図した状態 first: 100 first: 100 通る
    実際の状態 first: 20 first: 20 通る
    もし片方だけ残っていたら first: 100 first: 20 落ちる

    中途半端に消えていれば、ビルドが落ちて気づけました。きれいに全部消えたので、辻褄が合ってしまったわけです。

    ビルドが検証しているのは「矛盾が無いこと」であって、「意図が反映されていること」ではありません。何も変えていないコードは、当然のように矛盾しません。

    緑は「壊れていない」の意味

    CIやビルドの成功は、壊れていないことの証明です。直っていることの証明ではありません。この2つを同じものだと思っていると、今回のように「全部が緑なのに何も進んでいない」状態を見逃します。

    では、生成されたファイルの差分は何だったのか

    もうひとつ不思議だったのが、消えているのに gql.ts だけ差分が出ていたことです。

    調べると、生成されていたのは別のクエリの型でした。sitemap用に書いた、こういう文字列です。

    sitemapPosts.ts
    const POSTS_FOR_SITEMAP = /* GraphQL */ `
      query GetPostsForSitemap {
        posts(first: 100) { ... }
      }
    `;

    このファイルは、型生成に依存したくないからこそ素の文字列で書いていました。ところが先頭に付けた /* GraphQL */ という目印を、codegenが「これはクエリだ」と認識して走査対象に含めていました。

    結果として、型生成を避けるために作ったファイルが、型生成の対象になっていたわけです。意図とは正反対でした。

    失敗のおかげで見つかった

    この矛盾は、単体では表に出ません。使われない型がひっそり生成されるだけで、何も壊れないからです。別の失敗を調べていて、たまたま見つかりました。目印を外して、対象から除きました。

    次から何を見るか

    再発を防ぐために増やした確認は、1つだけです。

    コミットする前に
    git status --short

    並んでいるファイルの数を数えます。5ファイル触ったなら、5ファイル並んでいるはずです。2つしか無ければ、その時点でおかしい。

    やり直したときは、実際にこれで確認しました。7ファイル(手で書いた5つ + 生成された2つ)が並んでいることを見てからコミットしています。

    1回目 2回目
    コミットに入ったファイル 2 7
    ビルド 通った 通った
    本番の日時 変わらず Z が付いた

    1行目だけが、両者を区別できていました。

    今回の学び

    まず、ビルドの成功は「壊れていない」であって「直っている」ではないこと。今回は変更が丸ごと消えたことで整合が取れてしまい、すべての工程が緑になりました。矛盾を検査する仕組みは、何もしていない状態を歓迎します。

    次に、結果ではなく、入力を確認すること。「本番の表示が変わったか」で気づくのは最後です。コミットに何ファイル入ったかを見れば、pushする前に止められました。確認は下流より上流に置くほうが安いです。

    そして、git reset --hard は取り消しではないこと。コミットしていない作業に対しては、単なる破棄です。状態を整えたいだけなら stash を使う、という区別を持っておくと事故が減ります。

    最後に、避けたはずの仕組みに、目印ひとつで巻き込まれること。型生成から独立させるつもりのファイルが、コメント1つで走査対象になっていました。意図は、コードのどこかに具体的な形で表れていないと守られません。