RSpecのlet_it_be:test-profで高速化するDB生成の使い方

RSpecのlet_it_be:test-profで高速化するDB生成の使い方

# let! を let_it_be に変えるだけ
let_it_be(:user) { create(:user) }   # グループ全体で1回だけ生成(let!はitの数だけ生成)

let! との違い:let! は各 it の前に毎回DBへINSERTします。let_it_be はexample group内で1回だけINSERTし、テスト間の副作用はトランザクションで隔離します。


はじめに

RSpecのテストでDBへのレコード生成が多くなると、let! の繰り返し実行がボトルネックになります。let_it_betest-prof gemが提供するヘルパーで、同じexample group内でレコードを1回だけ生成してテストを高速化できます。


let! の何が遅いか

let! は各 it の前に毎回評価されます。

RSpec.describe Post, type: :model do
  let!(:user)     { create(:user) }     # 各itの前に実行
  let!(:category) { create(:category) } # 各itの前に実行
  let!(:post)     { create(:post, user: user, category: category) }

  it "タイトルを持つ"  do ... end  # ← user, category, post を生成
  it "公開できる"      do ... end  # ← user, category, post を生成(また)
  it "下書きに戻せる"  do ... end  # ← user, category, post を生成(また)
end

itが3つあれば3回、10個あれば10回DBへのINSERTが発生します。大きなテストスイートではこれが積み重なり、実行時間が長くなります。


let_it_be の仕組み

let_it_be はexample group(describecontext)単位で1回だけレコードを生成し、各 it 間はトランザクションでラップして副作用を隔離します。

RSpec.describe Post, type: :model do
  let_it_be(:user)     { create(:user) }     # グループ全体で1回だけ生成
  let_it_be(:category) { create(:category) }
  let_it_be(:post)     { create(:post, user: user, category: category) }

  it "タイトルを持つ"  do ... end  # ← 生成済みのレコードを使う
  it "公開できる"      do ... end  # ← 生成済みのレコードを使う
  it "下書きに戻せる"  do ... end  # ← 生成済みのレコードを使う
end

itが3つでも10個でも、INSERTは1回ずつです。


インストール

test-prof gemをGemfileに追加します。

# Gemfile
group :test do
  gem 'test-prof'
end
bundle install

spec/spec_helper.rb または spec/rails_helper.rb に設定を追加します。

# spec/rails_helper.rb
require 'test_prof/recipes/rspec/let_it_be'

基本的な使い方

let!let_it_be に置き換えるだけで使えます。

RSpec.describe User, type: :model do
  let_it_be(:user) { create(:user, name: "田中太郎") }

  it "名前を持つ" do
    expect(user.name).to eq("田中太郎")
  end

  it "有効である" do
    expect(user).to be_valid
  end
end

ネストした context でも使える

RSpec.describe Post, type: :model do
  let_it_be(:user) { create(:user) }

  context "公開状態の場合" do
    let_it_be(:post) { create(:post, user: user, published: true) }

    it "公開されている" do
      expect(post.published?).to be true
    end
  end

  context "下書き状態の場合" do
    let_it_be(:post) { create(:post, user: user, published: false) }

    it "非公開である" do
      expect(post.published?).to be false
    end
  end
end

注意点:デフォルトでimmutable

let_it_be で生成したオブジェクトはデフォルトで変更不可(frozen)扱いになります。テスト内でオブジェクトの属性を変更しても、他のテストには影響しません。

let_it_be(:user) { create(:user, name: "田中太郎") }

it "名前を変更しても他のテストに影響しない" do
  user.name = "鈴木花子"   # メモリ上の変更
  expect(user.name).to eq("鈎木花子")
end

it "元の名前のまま" do
  expect(user.name).to eq("田中太郎")   # 影響を受けない
end

DBを更新した場合は reload: true

テスト内でDBを直接更新し、次のテストでもDBの状態を反映させたい場合は reload: true を使います。

let_it_be(:user, reload: true) { create(:user, name: "田中太郎") }

it "DBを更新する" do
  user.update!(name: "鈴木花子")
  # 次のit開始前にuser.reloadが自動で呼ばれる
end

it "常にDBの最新状態を参照する" do
  expect(user.name).to eq("田中太郎")   # reloadされて元の値に戻っている
end

再取得が必要な場合は refind: true

オブジェクトをDBから再取得(新しいインスタンスとして)したい場合は refind: true を使います。

let_it_be(:post, refind: true) { create(:post) }

reload: true は同じオブジェクトをリロード、refind: true は新しいオブジェクトとして取得し直す違いがあります。


let・let!・let_it_be の比較

let let! let_it_be
評価タイミング 初めて呼ばれたとき 各itの前 グループで1回だけ
DB生成回数 呼ばれた回数 itの数 × 1回 1回
it間の副作用 なし トランザクションでロールバック トランザクションでロールバック
向いているケース 軽い計算・オブジェクト DBが必要な前提条件 変更しない共通レコード

どこに使うか

let_it_be はすべてのケースで使えるわけではありません。

向いているケース: - テスト内でレコードを更新・削除しない共通の前提データ - 親レコード(User・Categoryなど)の生成

向いていないケース: - テスト内でレコードを更新・削除する場合(let!reload: true を使う) - テストごとに異なる状態のレコードが必要な場合

基本は let を使い、DBレコードが必要で変更しない共通データは let_it_be、テストごとに状態が変わるものは let! という使い分けが実践的です。


まとめ

  • let_it_be はexample group単位でDBレコードを1回だけ生成する
  • let! の代替として使うとテストの実行速度が大幅に改善できる
  • デフォルトでimmutableなため他のテストへの副作用がない
  • DBを更新する場合は reload: true、再取得は refind: true を使う

letlet! の基本的な違いは「RSpecのletとlet!:遅延評価と即時評価の違いと使い分け」を参照してください。

RSpecの基本的な書き方は「RSpec入門:インストールからモデルスペックの書き方まで」を参照してください。

TypeScriptで通知バッジを実装する:ベルアイコンに数字を表示する

TypeScriptで通知バッジを実装する:ベルアイコンに数字を表示する

SNSやチャットアプリでよく見る、ベルアイコン右上に数字を表示する通知バッジの実装です。ライブラリなしの HTML + CSS + TypeScript で作れます。


HTML構造

position: relative の親要素に対して、バッジを position: absolute で重ねます。

<div class="bell-wrapper">
  <svg class="bell-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
  </svg>
  <span class="badge" id="badge"></span>
</div>

CSS:バッジの配置とアニメーション

.bell-wrapper {
  position: relative;
  display: inline-block;
}

.badge {
  position: absolute;
  top: 0;
  right: 0;
  background: #e53935;
  color: white;
  font-size: 15px;
  font-weight: 700;
  min-width: 26px;
  height: 26px;
  border-radius: 13px;
  padding: 0 7px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px solid white; /* アイコンとの境界を明確にする */
  opacity: 0;
  transform: scale(0);
  transition: opacity 0.15s, transform 0.15s;
}

.badge.visible {
  opacity: 1;
  transform: scale(1);
}

.badge.pop {
  animation: badge-pop 0.25s ease-out;
}

@keyframes badge-pop {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.4); }
  100% { transform: scale(1); }
}

min-width を使うことで、1桁は円形、2桁以上は横長の丸角矩形になります。


TypeScript:カウントの更新

const badge = document.getElementById('badge') as HTMLSpanElement
let count = 0

function updateBadge(): void {
  if (count === 0) {
    badge.classList.remove('visible')
    badge.textContent = ''
    return
  }
  badge.textContent = count > 9 ? '9+' : String(count)
  badge.classList.add('visible')

  // クラスを一度外してから付け直すことでアニメーションをリセット
  badge.classList.remove('pop')
  void badge.offsetWidth
  badge.classList.add('pop')
}

// 通知を追加
function addNotification(): void {
  count++
  updateBadge()
}

// 通知をクリア(ベルクリック・クリアボタンどちらでも呼ぶ)
function clearNotifications(): void {
  count = 0
  updateBadge()
}

void badge.offsetWidth は DOM の再描画を強制してアニメーションをリセットするイディオムです。これがないと、連続クリック時に pop アニメーションが再生されません。


イベントの登録

const bellWrapper = document.querySelector<HTMLElement>('.bell-wrapper')!
const btnAdd = document.getElementById('btn-add') as HTMLButtonElement
const btnClear = document.getElementById('btn-clear') as HTMLButtonElement

bellWrapper.addEventListener('click', clearNotifications)
btnAdd.addEventListener('click', addNotification)
btnClear.addEventListener('click', clearNotifications)

実際のアプリではベルクリック時に通知一覧へ遷移しますが、clearNotifications() を呼ぶ位置は同じです。


10件超の表示

badge.textContent = count > 9 ? '9+' : String(count)

上限を変えたい場合は 9 の部分を変えるだけです。99+ にするなら count > 99 ? '99+' : String(count) にします。


まとめ

やること コード
バッジの配置 親に position: relative、バッジに position: absolute; top: 0; right: 0
表示・非表示 visible クラスで opacitytransform: scale を切り替える
出現アニメーション @keyframes + クラスの付け外しでリセット
10件超の表示 count > 9 ? '9+' : String(count)
アニメーションリセット void badge.offsetWidth で強制再描画

TypeScriptドラッグ&ドロップ発展:ゴースト要素でカンバンボードを実装する

TypeScriptドラッグ&ドロップ発展:ゴースト要素でカンバンボードを実装する

HTML5 Drag and Drop API入門の発展編です。マウスイベント(mousedownmousemovemouseup)を使ってゴースト要素をカーソルに追従させる実装を紹介します。


HTML5 Drag APIとの違い

HTML5 Drag and Drop APIはブラウザがドラッグ画像の描画を担当するため、見た目のカスタマイズに限界があります。マウスイベントで自前実装すると:

  • ゴースト要素がカーソルにぴったり追従する
  • ドラッグ中のスタイルを自由に制御できる
  • canvasSVG 要素も問題なくドラッグできる

HTML構造

<div class="container">
  <div class="list" id="todo">
    <h3>Todo</h3>
    <div class="item">デザインレビュー</div>
    <div class="item">APIの実装</div>
    <div class="item">テストを書く</div>
  </div>
  <div class="list" id="done">
    <h3>Done</h3>
  </div>
</div>

CSS:ドラッグ中のスタイル

.item {
  cursor: grab;
  user-select: none;
}

.list.drag-over {
  border-color: #4c9aff;
  background: #e8f4ff;
}

user-select: none でドラッグ中にテキストが選択されるのを防ぎます。


mousedown:ドラッグ開始とゴースト生成

let dragging: HTMLElement | null = null
let ghost: HTMLElement | null = null
let offsetX = 0
let offsetY = 0

document.querySelectorAll<HTMLElement>('.item').forEach(item => {
  item.addEventListener('mousedown', (e: MouseEvent) => {
    e.preventDefault() // テキスト選択を防ぐ
    dragging = item

    const rect = item.getBoundingClientRect()
    offsetX = e.clientX - rect.left
    offsetY = e.clientY - rect.top

    // 元アイテムのクローンをゴーストとして追加
    ghost = item.cloneNode(true) as HTMLElement
    ghost.style.position = 'fixed'
    ghost.style.width = rect.width + 'px'
    ghost.style.left = rect.left + 'px'
    ghost.style.top = rect.top + 'px'
    ghost.style.margin = '0'
    ghost.style.opacity = '0.85'
    ghost.style.pointerEvents = 'none' // マウスイベントを透過させる
    ghost.style.zIndex = '100'
    document.body.appendChild(ghost)

    item.style.opacity = '0.3' // 元アイテムを半透明に
  })
})

getBoundingClientRect() でアイテムの位置を取得し、クリックした位置からのオフセットを記録します。ゴーストに pointerEvents: none を設定しないと mouseup 時にゴーストが邪魔になります。


mousemove:ゴーストを追従させる

document.addEventListener('mousemove', (e: MouseEvent) => {
  if (!ghost) return

  ghost.style.left = (e.clientX - offsetX) + 'px'
  ghost.style.top = (e.clientY - offsetY) + 'px'

  // ドロップ先をハイライト
  document.querySelectorAll<HTMLElement>('.list').forEach(list => {
    const rect = list.getBoundingClientRect()
    const inside =
      e.clientX >= rect.left && e.clientX <= rect.right &&
      e.clientY >= rect.top && e.clientY <= rect.bottom
    list.classList.toggle('drag-over', inside)
  })
})

elementFromPoint() ではなく座標比較でハイライト判定をしているのは、ゴーストが pointerEvents: none でも elementFromPoint() の結果に影響しないようにするためです。


mouseup:ドロップ処理

document.addEventListener('mouseup', (e: MouseEvent) => {
  if (!dragging || !ghost) return

  // ゴーストを一時的に隠してドロップ先を判定
  ghost.style.visibility = 'hidden'
  const el = document.elementFromPoint(e.clientX, e.clientY)
  const list = el?.closest<HTMLElement>('.list')

  dragging.style.opacity = ''
  if (list) list.appendChild(dragging)

  document.body.removeChild(ghost)
  ghost = null
  dragging = null
  document.querySelectorAll('.list').forEach(l => l.classList.remove('drag-over'))
})

elementFromPoint() 直前にゴーストを visibility: hidden にすることで、ゴーストの下にある要素を正しく取得できます。


TypeScriptの型まとめ

mousedown / mousemove / mouseup のイベント MouseEvent
getBoundingClientRect() の戻り値 DOMRect
cloneNode(true) の戻り値(キャスト必要) HTMLElement
elementFromPoint() の戻り値 Element | null
closest('.list') の戻り値 Element | null

HTML5 Drag APIとの比較

項目 HTML5 Drag API マウスイベント実装
コード量 少ない 多い
ゴースト追従 ブラウザ任せ(カスタム困難) 自前で自由に制御
canvasSVG 動作不安定 問題なし
タッチデバイス △(touch-action 要調整) pointermove に変えれば対応可
向いている場面 シンプルな並び替え リッチなカンバン・エディタ

まとめ

やること コード
ドラッグ開始 mousedown でゴースト生成・オフセット記録
追従 mousemove でゴーストの left/top を更新
ドロップ先判定 ゴーストを visibility: hidden にしてから elementFromPoint()
ドロップ mouseup でゴースト削除・アイテムを移動
  • pointerEvents: none をゴーストに設定しないと mouseup が拾えない
  • elementFromPoint() の前にゴーストを隠すのが判定の要
  • タッチ対応するなら mousedownpointerdownmousemovepointermovemouseuppointerup に置き換える

mise run 入門:mise.toml でタスクを定義して Makefile を置き換える

mise run 入門:mise.toml でタスクを定義して Makefile を置き換える

miseのタスク機能を使うと、mise.toml にビルド・テスト・lintなどのコマンドをまとめて定義できます。make コマンドに頼らずに、プロジェクト共通のコマンドをシンプルに管理できます。


基本的なタスク定義

mise.toml[tasks.タスク名] セクションを追加します。

[tasks.build]
description = "ビルドを実行する"
run = "npm run build"

[tasks.test]
description = "テストを実行する"
run = "npm test"

[tasks.lint]
description = "Lint を実行する"
run = "npx eslint src/"

実行は mise run または短縮形の mise r を使います。

mise run build
mise r test   # 短縮形

タスク一覧を確認する

mise tasks ls
build  ビルドを実行する
lint   Lint を実行する
test   テストを実行する

description を書いておくとチームメンバーが一覧を見たときに意図が伝わります。


タスクを依存関係でまとめる

depends で複数タスクをまとめて実行できます。

[tasks.ci]
description = "CI 相当のタスクをまとめて実行する"
depends = ["lint", "test", "build"]
mise run ci
[lint] $ npx eslint src/
[test] $ npm test
[build] $ npm run build
...
Finished in 3.2s

depends に指定したタスクは並列実行されます。順序が重要な場合は depends の代わりに run に連続コマンドを書きます。


複数コマンドを実行する

1つのタスクに複数のコマンドを書くには配列にします。

[tasks.setup]
description = "依存インストールとビルド"
run = [
  "npm install",
  "npm run build",
]

環境変数を渡す

タスクに環境変数を設定できます。

[tasks.dev]
description = "開発サーバーを起動する"
run = "npm run dev"
env = { PORT = "3000", NODE_ENV = "development" }

Makefile との比較

機能 Makefile mise タスク
タスク定義 target: deps [tasks.name]
タスク一覧 make help(要定義) mise tasks ls(標準)
並列実行 make -j depends に指定
環境変数 export VAR=value env = { VAR = "value" }
インストール不要 make はほぼ標準搭載 mise が必要
構文 独自(タブ必須など) TOML で読みやすい

mise を使った Node.js・Ruby の環境構築については「mise で Node.js をインストールする方法」を参照してください。


まとめ

コマンド 説明
mise tasks ls タスク一覧を表示
mise run <task> タスクを実行
mise r <task> mise run の短縮形
depends = [...] 複数タスクを依存関係でまとめる
env = { ... } タスクに環境変数を設定する
  • mise.toml に書くのでプロジェクトのバージョン管理(git)に含められる
  • description を書くと mise tasks ls で意図が伝わる
  • depends を使うと lint → test → build のような流れを1コマンドにまとめられる

TypeScript ESLint 設定入門:flat config で型チェックを有効にする

TypeScript ESLint 設定入門:flat config で型チェックを有効にする

TypeScriptプロジェクトにESLintを導入する最小手順です。typescript-eslint v8以降は ESLint の新しい設定形式(flat config)をデフォルトで使います。


インストール

npm install --save-dev eslint typescript-eslint

typescript-eslint 1パッケージに parser・plugin・設定ヘルパーがすべて含まれています。以前の @typescript-eslint/parser@typescript-eslint/eslint-plugin を個別にインストールする必要はありません。


設定ファイル(基本)

プロジェクトルートに eslint.config.mjs を作成します。

// eslint.config.mjs
import tseslint from 'typescript-eslint'

export default tseslint.config(
  tseslint.configs.recommended,
)

recommended には TypeScript でよく使われるルールが含まれています。

npx eslint src/
src/example.ts
  2:22  error  Unexpected any. Specify a different type     @typescript-eslint/no-explicit-any
  7:7   error  'unused' is assigned a value but never used  @typescript-eslint/no-unused-vars

型情報を使うルールを有効にする

recommended の上位にあたる recommendedTypeChecked を使うと、TypeScriptの型情報を使ったルールが追加されます。

// eslint.config.mjs
import tseslint from 'typescript-eslint'

export default tseslint.config(
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
)

tsconfig.json が必要です。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true
  },
  "include": ["src"]
}

型情報を使うルールの例:

// Promise を await せず放置(no-floating-promises)
async function fetchData() {
  return Promise.resolve('data')
}

fetchData() // error: Promises must be awaited
src/example.ts
  7:1  error  Promises must be awaited  @typescript-eslint/no-floating-promises

no-floating-promises はバグの原因になりやすい「awaitし忘れ」を検出するルールで、型情報なしでは動きません。


package.json にスクリプトを追加する

{
  "scripts": {
    "lint": "eslint src/",
    "lint:fix": "eslint src/ --fix"
  }
}
npm run lint
npm run lint:fix

設定 型情報 速度 向いているケース
recommended 不要 速い 既存プロジェクトへの導入・CI コスト重視
recommendedTypeChecked 必要(tsconfig) やや遅い 新規プロジェクト・型安全を厳密に保ちたい

旧設定形式(eslintrc)との違い

ESLint v9 から eslint.config.js(flat config)が標準になりました。旧形式(.eslintrc.json など)は v9 以降は非推奨です。

項目 旧形式(eslintrc) 新形式(flat config)
ファイル名 .eslintrc.json / .eslintrc.js eslint.config.js / .mjs
parser 設定 "parser": "@typescript-eslint/parser" tseslint.config() に組み込み済み
plugin 設定 "plugins": ["@typescript-eslint"] 同上
extends "extends": ["plugin:@typescript-eslint/recommended"] tseslint.configs.recommended

まとめ

やること コード
インストール npm install --save-dev eslint typescript-eslint
基本設定 tseslint.configs.recommended
型チェックあり tseslint.configs.recommendedTypeChecked + parserOptions
Lint 実行 npx eslint src/
  • typescript-eslint v8 以降は flat config(eslint.config.mjs)を使う
  • 型情報を使うルール(no-floating-promises など)を有効にするには recommendedTypeCheckedtsconfig.json が必要
  • 旧形式(.eslintrc)は ESLint v9 以降非推奨

vhs入門:ターミナル操作をGIFに録画するコマンドラインツール

vhs入門:ターミナル操作をGIFに録画するコマンドラインツール

vhs はターミナル操作を GIF や動画として録画できるツールです。.tape というスクリプトファイルに操作を書いておけば、何度でも同じ GIF を再生成できます。

vhs デモ:tape スクリプトで自動録画


vhs とは

Charm が開発する OSS のターミナル録画ツールです。

画面録画ソフトと違い、操作をコードで記述します。

Type "git status"
Enter
Sleep 1s

このように書いたファイルを vhs demo.tape で実行すると GIF が生成されます。スクリプトなので Git で管理でき、内容を変えて再録画も簡単です。


インストール

brew install vhs

依存として ttydffmpeg が自動的に入ります。

バージョン確認:

vhs --version
# vhs version 0.11.0

tape ファイルの基本構文

出力ファイルを指定する

Output demo.gif

Output は tape の先頭に書きます。.gif のほか .mp4.webm も指定できます。

見た目を設定する

Set FontSize 14
Set Width 1200
Set Height 600
Set Padding 20
Set TypingSpeed 80ms
設定 説明
FontSize フォントサイズ(px)
Width / Height 画面サイズ(px)。最低 120×120 必要
Padding 余白(px)
TypingSpeed 1文字あたりの入力速度

操作を記述する

Type "git status"   # 文字を入力
Enter               # Enter キー
Space               # Space キー
Down                # 下矢印
Up                  # 上矢印
Sleep 1s            # 待機(ms / s 単位)

セットアップを非表示にする

録画に映したくない準備コマンドは Hide / Show で囲みます。

Hide
Type "cd /tmp/my-project"
Enter
Sleep 500ms
Show

# ここから録画に映る
Type "git log --oneline"
Enter

実際に試してみる

最小限の tape

Output demo.gif

Set Width 800
Set Height 300

Type "echo 'Hello, vhs!'"
Enter
Sleep 1s

これを demo.tape として保存し、実行します。

vhs demo.tape

カレントディレクトリに demo.gif が生成されます。

プロジェクトでの管理方法

project/
├── sandbox/
│   └── {記事名}/
│       ├── demo.tape    # Git 管理(再録画できる)
│       └── setup.sh     # デモ環境のセットアップスクリプト
└── assets/
    └── {記事名}/
        └── demo.gif     # .gitignore 済み(生成物)

tape スクリプトだけ Git に入れておけば、環境が変わっても vhs demo.tape で同じ GIF を再生成できます。


活用例:lazygit のデモ

この GIF は vhs で録画したものです(lazygit入門 の記事より)。

lazygit でファイルをステージしてコミットするデモ

使った tape スクリプト(抜粋):

Output assets/lazygit-intro/demo.gif

Set Shell zsh
Set FontSize 14
Set Width 1200
Set Height 650

Hide
Type "bash sandbox/lazygit-intro/setup.sh"
Enter
Sleep 3s
Type "clear"
Enter
Show

Type "lazygit"
Enter
Sleep 3s

Space    # ファイルをステージ
Sleep 700ms
Down
Space
Sleep 700ms

Type "c"                              # コミットメッセージ入力
Sleep 800ms
Type "add greet function and update app"
Enter
Sleep 2500ms

Type "q"

TUI ツールのように動きが重要なものは、テキストの説明より GIF のほうが一目で伝わります。


まとめ

コマンド 説明
Output demo.gif 出力ファイルを指定
Set Width 1200 画面幅(px)
Type "コマンド" 文字を入力
Enter Enter キー
Sleep 1s 待機
Hide / Show 録画の一時停止・再開
vhs demo.tape GIF を生成
  • tape ファイルはコードなので Git 管理・再録画が可能
  • Hide / Show でセットアップを非表示にできる
  • Width・Height はピクセル単位(最低 120×120)

ターミナルの TUI 操作や複数ステップのコマンド解説に特に効果的です。

git revertでコミットを取り消す方法:PR単位のrevertまで解説

git revertでコミットを取り消す方法:PR単位のrevertまで解説

git revert は、コミットの内容を打ち消す新しいコミットを作るコマンドです。

git reset のように履歴を書き換えないため、すでに push 済みのブランチでも安全に使えます。障害対応でマージした PR を丸ごと取り消したいときにも使えます。

git revert と git reset の違い

コマンド 履歴の扱い push 済みでも使える
git revert 打ち消すコミットを追加
git reset 履歴自体を書き換える △(force push が必要)

共有ブランチ(main / develop など)では git revert を使うのが原則です。


直前のコミットを取り消す

最も基本的な使い方です。

git revert HEAD

エディタが開いてコミットメッセージを確認できます。そのまま保存すると "Revert "元のメッセージ"" というコミットが作られます。

エディタを開かずに自動でコミットするには --no-edit を使います。

git revert HEAD --no-edit

実行後のログ:

$ git log --oneline
c8cfa97 Revert "add b"
0a22f22 add b     ← 取り消し対象(ログには残る)
036e604 add a
27466d9 initial

特定のコミットを取り消す

直前ではなく、過去の任意のコミットを取り消したいときはハッシュを指定します。

git log --oneline
# 例:
# 0a22f22 add b
# 036e604 add a   ← これを取り消したい

git revert 036e604 --no-edit

add a の内容だけを打ち消すコミットが追加されます(その後の add b は残ります)。


複数コミットをまとめて取り消す

git revert HEAD~2..HEAD --no-edit

HEAD~2..HEAD は「HEAD の2つ前(含まず)から HEAD(含む)まで」の範囲です。新しいコミットから順番に revert コミットが作られます。


PR 単位で revert する(障害対応)

マージコミット(PR のマージ)を revert するには -m 1 オプションが必要です。

なぜ -m 1 が必要か

マージコミットには2つの親があります。

        feature branch
        ↓
aa4e6e4 Merge pull request #1    ← 親1: main、親2: feature
7c8bc59 initial (main)
45c336d add feature (feature)

-m 1 は「親1(main ブランチ側)を正とみなして revert する」という指定です。通常は -m 1 で問題ありません。

手順

# マージコミットのハッシュを確認
git log --oneline
# 例:aa4e6e4 Merge pull request #1 from feature

# revert(マージコミットのハッシュを指定)
git revert -m 1 aa4e6e4 --no-edit

実行後:

$ git log --oneline
1d47fce Revert "Merge pull request #1 from feature"
aa4e6e4 Merge pull request #1 from feature
7c8bc59 initial

PR でマージされた変更がすべて取り消されます。

GitHub での手順(PR ベースの障害対応フロー)

# 1. main の最新を取得
git fetch origin
git checkout main
git pull origin main

# 2. revert 用のブランチを作成
git checkout -b revert/pr-1

# 3. マージコミットのハッシュを確認
git log --oneline origin/main | head -5

# 4. revert コミットを作成
git revert -m 1 <merge-commit-hash> --no-edit

# 5. push して PR を作成
git push origin revert/pr-1
gh pr create --title "Revert PR #1" --body "障害対応のため #1 を revert"

コンフリクトが発生した場合

revert 中にコンフリクトが起きた場合は以下の手順で解消します。

# コンフリクトしているファイルを修正後
git add <ファイル>
git revert --continue

revert をやめたい場合は:

git revert --abort

まとめ

操作 コマンド
直前のコミットを取り消す git revert HEAD
特定のコミットを取り消す git revert <hash>
複数コミットをまとめて取り消す git revert HEAD~n..HEAD
マージコミット(PR)を取り消す git revert -m 1 <merge-commit>
エディタを省略 --no-edit を追加
コンフリクト解消後に続行 git revert --continue
途中でキャンセル git revert --abort

コミット履歴の操作には「git log でコミット履歴を検索する方法」も参考にしてください。