Handling Git Submodule Changes with Codex

I (well, codex) created a pre-commit hook for the work-around process in my root repo (A fork of HA core dev container for developing a local growatt custom component, duckducking for “ha-core l4m4re github” should lead you right to it), where the root repo primarily acts as the devcontainer wrapper for the Growatt and broker projects I’m working on.

A quick&dirty git diff for the workaround:

diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000..4244c61d2dc
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,14 @@
+# Codex Workflow Notes
+
+This repository primarily acts as the devcontainer wrapper for the Growatt and broker projects. The new git hook workflow keeps submodule changes out of commits while still preserving work as patches.
+
+## Submodule patch workflow
+
+- The `script/bootstrap-linux-native` script installs a `pre-commit` hook that runs `script/submodule-pre-commit.sh`.
+- On commit, any modified submodule is converted into a patch stored at `submodule-patches/<submodule>/<timestamp>.patch`.
+- After the patch is captured, the hook resets the submodule back to the commit recorded in this repository so the pending commit only contains patch artifacts.
+- The patches are staged automatically. Commit them alongside your HA-core changes.
+- To apply a patch inside the actual submodule repository, use `script/apply-submodule-patches.sh` (it replays all stored patches by default, or accept paths).
+- Once the patch has been applied and a real submodule PR created, remember to remove the consumed patch file from this repository.
+
+This replaces the old `pre-commit install` step and avoids the heavy HA-core linting hooks when using this repo purely for the devcontainer workflow.
diff --git a/script/apply-submodule-patches.sh b/script/apply-submodule-patches.sh
new file mode 100755
index 00000000000..711788d250f
--- /dev/null
+++ b/script/apply-submodule-patches.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Apply stored submodule patches back into the respective submodule working trees.
+set -euo pipefail
+
+ROOT_DIR="$(realpath "$(dirname "$0")/..")"
+PATCH_ROOT="$ROOT_DIR/submodule-patches"
+
+if [[ ! -d "$PATCH_ROOT" ]]; then
+  echo "No submodule patches directory found at $PATCH_ROOT" >&2
+  exit 0
+fi
+
+if [[ $# -gt 0 ]]; then
+  declare -a PATCHES=()
+  for arg in "$@"; do
+    if [[ -f "$arg" ]]; then
+      PATCHES+=("$(realpath "$arg")")
+    elif [[ -f "$PATCH_ROOT/$arg" ]]; then
+      PATCHES+=("$(realpath "$PATCH_ROOT/$arg")")
+    else
+      echo "Patch file not found: $arg" >&2
+      exit 1
+    fi
+  done
+else
+  mapfile -t PATCHES < <(find "$PATCH_ROOT" -type f -name '*.patch' | sort)
+fi
+
+if [[ ${#PATCHES[@]} -eq 0 ]]; then
+  echo "No submodule patches to apply." >&2
+  exit 0
+fi
+
+for patch_path in "${PATCHES[@]}"; do
+  rel_path="${patch_path#$PATCH_ROOT/}"
+  submodule_path="${rel_path%/*}"
+  if [[ -z "$submodule_path" ]]; then
+    echo "Unable to determine submodule for patch $rel_path" >&2
+    exit 1
+  fi
+  target_dir="$ROOT_DIR/$submodule_path"
+  if [[ ! -d "$target_dir" ]]; then
+    echo "Submodule directory missing for patch $rel_path (expected $target_dir)" >&2
+    exit 1
+  fi
+
+  echo "Applying $rel_path to $submodule_path"
+  (cd "$target_dir" && git apply --3way "$patch_path")
+
+done
+
+echo "Patches applied. Review each submodule, make commits, and then remove or archive applied patches." >&2
diff --git a/script/bootstrap-linux-native b/script/bootstrap-linux-native
index 1a21c9d7d22..5dd83f6f170 100755
--- a/script/bootstrap-linux-native
+++ b/script/bootstrap-linux-native
@@ -133,11 +133,8 @@ if [[ -f requirements_test_pre_commit.txt ]]; then
   uv pip install -r requirements_test_pre_commit.txt || true
 fi
 
-# Install git hooks if pre-commit is available
-if command -v pre-commit >/dev/null 2>&1; then
-  log "Installing pre-commit git hooks..."
-  pre-commit install || true
-fi
+log "Installing submodule patch git hook..."
+./script/install-submodule-hooks.sh
 
 log "Bootstrap complete. Active environment: $(python -c 'import sys; print(sys.executable)')"
 log "Tip: Activate later with 'source .venv/bin/activate' before running tasks."
diff --git a/script/git-hooks/pre-commit b/script/git-hooks/pre-commit
new file mode 100755
index 00000000000..1cf51249aa7
--- /dev/null
+++ b/script/git-hooks/pre-commit
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Git pre-commit hook wrapper that captures submodule work as patches.
+set -euo pipefail
+
+ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
+if [[ -z "$ROOT_DIR" ]]; then
+  echo "Unable to determine repository root; skipping submodule patch capture." >&2
+  exit 0
+fi
+
+HOOK_SCRIPT="$ROOT_DIR/script/submodule-pre-commit.sh"
+if [[ ! -x "$HOOK_SCRIPT" ]]; then
+  echo "Submodule capture script not found at $HOOK_SCRIPT" >&2
+  exit 0
+fi
+
+"$HOOK_SCRIPT"
diff --git a/script/install-submodule-hooks.sh b/script/install-submodule-hooks.sh
new file mode 100755
index 00000000000..720662beca1
--- /dev/null
+++ b/script/install-submodule-hooks.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Install git hooks that capture submodule work as patches for later application.
+set -euo pipefail
+
+ROOT_DIR="$(realpath "$(dirname "$0")/..")"
+HOOK_SOURCE="$ROOT_DIR/script/git-hooks/pre-commit"
+HOOK_DEST="$ROOT_DIR/.git/hooks/pre-commit"
+
+if [[ ! -d "$ROOT_DIR/.git" ]]; then
+  echo "Skipping hook installation; .git directory not found." >&2
+  exit 0
+fi
+
+mkdir -p "$(dirname "$HOOK_DEST")"
+cp "$HOOK_SOURCE" "$HOOK_DEST"
+chmod +x "$HOOK_DEST"
+
+echo "Installed pre-commit hook to capture submodule patches." >&2
diff --git a/script/submodule-pre-commit.sh b/script/submodule-pre-commit.sh
new file mode 100755
index 00000000000..ba144cb4b12
--- /dev/null
+++ b/script/submodule-pre-commit.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Capture submodule changes as patch files during pre-commit and reset submodules.
+set -euo pipefail
+
+unset GIT_INDEX_FILE
+
+ROOT_DIR="$(git rev-parse --show-toplevel)"
+PATCH_ROOT="$ROOT_DIR/submodule-patches"
+mkdir -p "$PATCH_ROOT"
+
+mapfile -t SUBMODULES < <(git config --file "$ROOT_DIR/.gitmodules" --get-regexp path 2>/dev/null | awk '{print $2}')
+
+if [[ ${#SUBMODULES[@]} -eq 0 ]]; then
+  exit 0
+fi
+
+created_patches=()
+
+for submodule in "${SUBMODULES[@]}"; do
+  SUB_PATH="$ROOT_DIR/$submodule"
+  if [[ ! -d "$SUB_PATH/.git" && ! -f "$SUB_PATH/.git" ]]; then
+    continue
+  fi
+
+  status=$(git -C "$SUB_PATH" status --porcelain --untracked-files=all)
+  if [[ -z "$status" ]]; then
+    continue
+  fi
+
+  mapfile -t UNTRACKED < <(git -C "$SUB_PATH" ls-files --others --exclude-standard)
+  if [[ ${#UNTRACKED[@]} -gt 0 ]]; then
+    (cd "$SUB_PATH" && git add -N -- "${UNTRACKED[@]}")
+  fi
+
+  timestamp=$(date -u +"%Y%m%d-%H%M%S")
+  module_patch_dir="$PATCH_ROOT/$submodule"
+  mkdir -p "$module_patch_dir"
+  patch_file="$module_patch_dir/${timestamp}.patch"
+
+  if ! git -C "$SUB_PATH" diff --binary HEAD > "$patch_file"; then
+    (cd "$SUB_PATH" && git reset --mixed HEAD)
+    rm -f "$patch_file"
+    echo "Failed to create patch for submodule $submodule" >&2
+    exit 1
+  fi
+
+  if [[ ! -s "$patch_file" ]]; then
+    (cd "$SUB_PATH" && git reset --mixed HEAD)
+    rm -f "$patch_file"
+    echo "Submodule $submodule reported changes but produced an empty patch." >&2
+    echo "Commit aborted. Please review the submodule state manually." >&2
+    exit 1
+  fi
+
+  (cd "$SUB_PATH" && git reset --hard HEAD >/dev/null)
+  (cd "$SUB_PATH" && git clean -fdx >/dev/null)
+  git submodule update --init --checkout "$submodule" >/dev/null
+
+  rel_patch="${patch_file#$ROOT_DIR/}"
+  git add "$rel_patch"
+  created_patches+=("$rel_patch")
+  echo "Captured $submodule changes into $rel_patch"
+done
+
+if [[ ${#created_patches[@]} -gt 0 ]]; then
+  printf '\nSubmodule patches staged for commit:\n' >&2
+  printf '  %s\n' "${created_patches[@]}" >&2
+  printf '\nUse script/apply-submodule-patches.sh to reapply them when updating submodules.\n' >&2
+fi
+
+exit 0

Only tested once so YMMV, but so far it seems to work nicely.