<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Git on Tarragon</title><link>https://tarrragon.github.io/blog/tags/git/</link><description>Recent content in Git on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 29 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/git/index.xml" rel="self" type="application/rss+xml"/><item><title>管理策略與選型</title><link>https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/management-strategies/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/management-strategies/</guid><description>&lt;p>Dotfile 管理的核心動作是把散落在家目錄各處的配置檔集中到一個 Git repo 裡版控。工具只是幫你處理「repo 裡的檔案怎麼對應到家目錄正確位置」這一層映射，選型看的是你的機器數量、OS 組合和 secret 需求。&lt;/p>
&lt;h2 id="git-bare-repo直接把家目錄當-work-tree">Git bare repo：直接把家目錄當 work tree&lt;/h2>
&lt;p>bare repo 的概念是在家目錄建一個沒有工作目錄的 Git 倉庫，然後用 alias 指定 &lt;code>--work-tree=$HOME&lt;/code>，讓 Git 直接追蹤家目錄下的檔案，不需要 symlink、不需要額外工具。&lt;/p>
&lt;p>初始化：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git init --bare &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/.dotfiles&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>在 shell 配置裡加一行 alias：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">alias&lt;/span> &lt;span class="nv">dotfiles&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;git --git-dir=&amp;#34;$HOME/.dotfiles&amp;#34; --work-tree=&amp;#34;$HOME&amp;#34;&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>之後所有 dotfile 操作都透過這個 alias：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">dotfiles add ~/.zshrc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">dotfiles commit -m &lt;span class="s2">&amp;#34;add zshrc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">dotfiles remote add origin git@github.com:you/dotfiles.git
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">dotfiles push -u origin main&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一件要做的事是隱藏未追蹤檔案。家目錄底下有成千上萬個檔案，如果不設定這一行，&lt;code>dotfiles status&lt;/code> 會列出所有未追蹤的東西：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">dotfiles config --local status.showUntrackedFiles no&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>新機器還原的流程：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git clone --bare git@github.com:you/dotfiles.git &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/.dotfiles&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">alias&lt;/span> &lt;span class="nv">dotfiles&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;git --git-dir=&amp;#34;$HOME/.dotfiles&amp;#34; --work-tree=&amp;#34;$HOME&amp;#34;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">dotfiles config --local status.showUntrackedFiles no
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">dotfiles checkout&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>checkout&lt;/code> 這步會把 repo 裡的檔案寫到家目錄。如果家目錄已經有同名檔案（例如系統預設的 &lt;code>.bashrc&lt;/code>），checkout 會失敗並列出衝突檔案，需要先手動備份或刪除。&lt;/p>
&lt;p>bare repo 適合配置量少、只管一台機器、不想安裝任何額外工具的人。它的限制是：概念對 Git 初學者不直覺（bare repo + work-tree 的組合不常見）、沒有模組化的概念（無法選擇性安裝某些配置）、多 profile 支援弱（不同機器要不同配置時只能靠 branch，長期維護困難）。&lt;/p>
&lt;h2 id="gnu-stowsymlink-農場管理器">GNU Stow：symlink 農場管理器&lt;/h2>
&lt;p>Stow 的概念是把 dotfile 集中放在一個普通目錄（如 &lt;code>~/dotfiles&lt;/code>），然後用 stow 指令在家目錄建立 symlink。stow 的核心規則是：package 目錄內的路徑結構，就是安裝後相對於目標目錄的路徑結構。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 安裝 stow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># macOS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">brew install stow
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Arch Linux&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">sudo pacman -S stow
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">sudo apt install stow&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>以 zsh 配置為例，目錄結構長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">~/dotfiles/zsh/.zshrc&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>執行 &lt;code>stow zsh&lt;/code> 後，stow 會在 &lt;code>$HOME&lt;/code> 建一個 symlink：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">~/.zshrc -&amp;gt; ~/dotfiles/zsh/.zshrc&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對於放在 &lt;code>~/.config/&lt;/code> 底下的工具（XDG 規範），目錄結構映射同樣的邏輯：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">~/dotfiles/nvim/.config/nvim/init.lua
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">~/dotfiles/nvim/.config/nvim/lua/plugins.lua&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>執行 &lt;code>stow nvim&lt;/code> 後：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">~/.config/nvim -&amp;gt; ~/dotfiles/nvim/.config/nvim&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>stow 會自動判斷該 symlink 整個目錄還是個別檔案——如果目標目錄不存在或目錄內所有檔案都由同一個 package 管理，stow 會 symlink 整個目錄（folding）；如果目標目錄已有其他檔案，stow 會展開（unfolding）成逐檔 symlink。&lt;/p>
&lt;p>初始化和日常操作：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 初始化&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">mkdir ~/dotfiles &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">cd&lt;/span> ~/dotfiles
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">git init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">git remote add origin git@github.com:you/dotfiles.git
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1"># 把現有 .zshrc 搬進 dotfiles&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">mkdir -p ~/dotfiles/zsh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">mv ~/.zshrc ~/dotfiles/zsh/.zshrc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/dotfiles &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> stow zsh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># 現在 ~/.zshrc 是一個 symlink，指向 ~/dotfiles/zsh/.zshrc&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1"># 日常修改：直接編輯，symlink 透通&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">vim ~/.zshrc &lt;span class="c1"># 實際編輯的是 ~/dotfiles/zsh/.zshrc&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/dotfiles &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> git add -A &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> git commit -m &lt;span class="s2">&amp;#34;update zshrc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="c1"># 新機器還原&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">git clone git@github.com:you/dotfiles.git ~/dotfiles
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/dotfiles &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> stow zsh git nvim tmux&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>批次安裝所有 package：&lt;/p></description><content:encoded><![CDATA[<p>Dotfile 管理的核心動作是把散落在家目錄各處的配置檔集中到一個 Git repo 裡版控。工具只是幫你處理「repo 裡的檔案怎麼對應到家目錄正確位置」這一層映射，選型看的是你的機器數量、OS 組合和 secret 需求。</p>
<h2 id="git-bare-repo直接把家目錄當-work-tree">Git bare repo：直接把家目錄當 work tree</h2>
<p>bare repo 的概念是在家目錄建一個沒有工作目錄的 Git 倉庫，然後用 alias 指定 <code>--work-tree=$HOME</code>，讓 Git 直接追蹤家目錄下的檔案，不需要 symlink、不需要額外工具。</p>
<p>初始化：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git init --bare <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.dotfiles&#34;</span></span></span></code></pre></div><p>在 shell 配置裡加一行 alias：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">alias</span> <span class="nv">dotfiles</span><span class="o">=</span><span class="s1">&#39;git --git-dir=&#34;$HOME/.dotfiles&#34; --work-tree=&#34;$HOME&#34;&#39;</span></span></span></code></pre></div><p>之後所有 dotfile 操作都透過這個 alias：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">dotfiles add ~/.zshrc
</span></span><span class="line"><span class="ln">2</span><span class="cl">dotfiles commit -m <span class="s2">&#34;add zshrc&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">dotfiles remote add origin git@github.com:you/dotfiles.git
</span></span><span class="line"><span class="ln">4</span><span class="cl">dotfiles push -u origin main</span></span></code></pre></div><p>第一件要做的事是隱藏未追蹤檔案。家目錄底下有成千上萬個檔案，如果不設定這一行，<code>dotfiles status</code> 會列出所有未追蹤的東西：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">dotfiles config --local status.showUntrackedFiles no</span></span></code></pre></div><p>新機器還原的流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git clone --bare git@github.com:you/dotfiles.git <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.dotfiles&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">alias</span> <span class="nv">dotfiles</span><span class="o">=</span><span class="s1">&#39;git --git-dir=&#34;$HOME/.dotfiles&#34; --work-tree=&#34;$HOME&#34;&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">dotfiles config --local status.showUntrackedFiles no
</span></span><span class="line"><span class="ln">4</span><span class="cl">dotfiles checkout</span></span></code></pre></div><p><code>checkout</code> 這步會把 repo 裡的檔案寫到家目錄。如果家目錄已經有同名檔案（例如系統預設的 <code>.bashrc</code>），checkout 會失敗並列出衝突檔案，需要先手動備份或刪除。</p>
<p>bare repo 適合配置量少、只管一台機器、不想安裝任何額外工具的人。它的限制是：概念對 Git 初學者不直覺（bare repo + work-tree 的組合不常見）、沒有模組化的概念（無法選擇性安裝某些配置）、多 profile 支援弱（不同機器要不同配置時只能靠 branch，長期維護困難）。</p>
<h2 id="gnu-stowsymlink-農場管理器">GNU Stow：symlink 農場管理器</h2>
<p>Stow 的概念是把 dotfile 集中放在一個普通目錄（如 <code>~/dotfiles</code>），然後用 stow 指令在家目錄建立 symlink。stow 的核心規則是：package 目錄內的路徑結構，就是安裝後相對於目標目錄的路徑結構。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 安裝 stow</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># macOS</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">brew install stow
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Arch Linux</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo pacman -S stow
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">sudo apt install stow</span></span></code></pre></div><p>以 zsh 配置為例，目錄結構長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">~/dotfiles/zsh/.zshrc</span></span></code></pre></div><p>執行 <code>stow zsh</code> 後，stow 會在 <code>$HOME</code> 建一個 symlink：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">~/.zshrc -&gt; ~/dotfiles/zsh/.zshrc</span></span></code></pre></div><p>對於放在 <code>~/.config/</code> 底下的工具（XDG 規範），目錄結構映射同樣的邏輯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">~/dotfiles/nvim/.config/nvim/init.lua
</span></span><span class="line"><span class="ln">2</span><span class="cl">~/dotfiles/nvim/.config/nvim/lua/plugins.lua</span></span></code></pre></div><p>執行 <code>stow nvim</code> 後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">~/.config/nvim -&gt; ~/dotfiles/nvim/.config/nvim</span></span></code></pre></div><p>stow 會自動判斷該 symlink 整個目錄還是個別檔案——如果目標目錄不存在或目錄內所有檔案都由同一個 package 管理，stow 會 symlink 整個目錄（folding）；如果目標目錄已有其他檔案，stow 會展開（unfolding）成逐檔 symlink。</p>
<p>初始化和日常操作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 初始化</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir ~/dotfiles <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/dotfiles
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">git init
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">git remote add origin git@github.com:you/dotfiles.git
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 把現有 .zshrc 搬進 dotfiles</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">mkdir -p ~/dotfiles/zsh
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">mv ~/.zshrc ~/dotfiles/zsh/.zshrc
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nb">cd</span> ~/dotfiles <span class="o">&amp;&amp;</span> stow zsh
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 現在 ~/.zshrc 是一個 symlink，指向 ~/dotfiles/zsh/.zshrc</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 日常修改：直接編輯，symlink 透通</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">vim ~/.zshrc  <span class="c1"># 實際編輯的是 ~/dotfiles/zsh/.zshrc</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nb">cd</span> ~/dotfiles <span class="o">&amp;&amp;</span> git add -A <span class="o">&amp;&amp;</span> git commit -m <span class="s2">&#34;update zshrc&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"># 新機器還原</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">git clone git@github.com:you/dotfiles.git ~/dotfiles
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nb">cd</span> ~/dotfiles <span class="o">&amp;&amp;</span> stow zsh git nvim tmux</span></span></code></pre></div><p>批次安裝所有 package：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> ~/dotfiles
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># stow 會把每個頂層目錄當成一個 package</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">stow */</span></span></code></pre></div><p>移除某個 package 的 symlink：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> ~/dotfiles <span class="o">&amp;&amp;</span> stow -D nvim</span></span></code></pre></div><p>stow 適合中等複雜度的配置管理。它的優勢是模組化（每個工具獨立一個 package、可選擇性安裝）和概念直覺（目錄結構就是安裝後的樣子）。它的限制是只管 symlink 映射，不管套件安裝；跨 OS 的路徑差異（macOS 和 Linux 某些工具的配置路徑不同）需要自己處理；stow 也不管 file permission——需要 0600 權限的 secret 檔（SSH private key、API token config）靠 symlink 繼承來源檔案權限，不能在部署過程中自動設定。</p>
<h2 id="yadmbare-repo-的升級版">yadm：bare repo 的升級版</h2>
<p>yadm 包裝了 Git bare repo 的操作，加上三個 bare repo 缺少的能力：alternate files（依 OS、hostname、甚至 user 條件選擇安裝不同版本的配置檔）、encrypt（用 GPG 或 OpenSSL 加密敏感檔案、不依賴外部密碼管理器）、bootstrap script（clone 後自動跑初始化）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 安裝</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">brew install yadm          <span class="c1"># macOS</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">sudo pacman -S yadm        <span class="c1"># Arch</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 初始化（等同 git init --bare + 自動設定 alias）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">yadm init
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">yadm remote add origin git@github.com:you/dotfiles.git
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 操作方式跟 Git 完全一樣</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">yadm add ~/.zshrc
</span></span><span class="line"><span class="ln">11</span><span class="cl">yadm commit -m <span class="s2">&#34;add zshrc&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">yadm push</span></span></code></pre></div><p>Alternate files 的概念是在同一個 repo 裡放多個版本的同一個檔案，yadm 依條件決定用哪一個：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">~/.config/alacritty/alacritty.toml##os.Darwin
</span></span><span class="line"><span class="ln">2</span><span class="cl">~/.config/alacritty/alacritty.toml##os.Linux</span></span></code></pre></div><p>macOS 上 yadm 自動 checkout Darwin 版本、Linux 上 checkout Linux 版本。比 stow 的 shell if-else 判斷更乾淨，比 chezmoi 的 Go template 學習曲線低。</p>
<p>yadm 適合想要 bare repo 的簡單性、但需要條件安裝或 secret 加密的人。它的限制是沒有 stow 的模組化概念（無法選擇性只安裝某些工具的配置）、沒有 chezmoi 的 template 細粒度（alternate files 是整個檔案切換，不是檔案內的段落條件）。</p>
<h2 id="chezmoi多機器-dotfile-管理工具">Chezmoi：多機器 dotfile 管理工具</h2>
<p>Chezmoi 是專為 dotfile 管理設計的工具，原生處理 template、secret 管理和多機器差異。它把 dotfile 存在自己的 source directory（<code>~/.local/share/chezmoi</code>），用 <code>chezmoi apply</code> 把檔案實際寫入目標位置（不是 symlink，是複製）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 安裝</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">brew install chezmoi        <span class="c1"># macOS</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">pacman -S chezmoi           <span class="c1"># Arch</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">sh -c <span class="s2">&#34;</span><span class="k">$(</span>curl -fsLS get.chezmoi.io<span class="k">)</span><span class="s2">&#34;</span>  <span class="c1"># 通用</span></span></span></code></pre></div><p>基本操作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 初始化（會建立 source directory 和 Git repo）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">chezmoi init
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 加入現有配置</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">chezmoi add ~/.zshrc
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">chezmoi add ~/.config/nvim
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 編輯（在 source directory 裡編輯，不是直接改家目錄的檔案）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">chezmoi edit ~/.zshrc
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 預覽差異</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">chezmoi diff
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 套用到家目錄</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">chezmoi apply
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 推上遠端</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">chezmoi <span class="nb">cd</span>  <span class="c1"># 進入 source directory</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">git add -A <span class="o">&amp;&amp;</span> git commit -m <span class="s2">&#34;update&#34;</span> <span class="o">&amp;&amp;</span> git push</span></span></code></pre></div><p>chezmoi 的核心優勢是 template。同一份配置檔在不同機器可以產生不同內容：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># chezmoi 的 source directory 裡，檔案名稱加 .tmpl 後綴</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># dot_zshrc.tmpl</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nb">export</span> <span class="nv">EDITOR</span><span class="o">=</span><span class="s2">&#34;nvim&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="o">{{</span>- <span class="k">if</span> eq .chezmoi.os <span class="s2">&#34;darwin&#34;</span> <span class="o">}}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nb">export</span> <span class="nv">HOMEBREW_PREFIX</span><span class="o">=</span><span class="s2">&#34;/opt/homebrew&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span><span class="nv">$HOMEBREW_PREFIX</span>/bin/brew shellenv<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="o">{{</span>- end <span class="o">}}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="o">{{</span>- <span class="k">if</span> eq .chezmoi.os <span class="s2">&#34;linux&#34;</span> <span class="o">}}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.local/bin:</span><span class="nv">$PATH</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="o">{{</span>- end <span class="o">}}</span></span></span></code></pre></div><p><code>chezmoi apply</code> 會根據當前機器的 OS 展開 template，macOS 上產生的 <code>.zshrc</code> 會包含 Homebrew 設定，Linux 上不會。</p>
<p>Secret 管理是另一個殺手功能。chezmoi 整合了 1Password、Bitwarden、pass、gopass、LastPass 等密碼管理器：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># dot_gitconfig.tmpl
</span></span><span class="line"><span class="ln">2</span><span class="cl">[user]
</span></span><span class="line"><span class="ln">3</span><span class="cl">    name = Your Name
</span></span><span class="line"><span class="ln">4</span><span class="cl">    email = {{ (onepasswordRead &#34;op://Personal/Git Config/email&#34;).value }}</span></span></code></pre></div><p><code>chezmoi apply</code> 時會即時從 1Password 拉值填入，secret 不會存在 Git repo 裡。</p>
<p>chezmoi 適合管理多台異質機器（macOS 工作機 + Linux 伺服器 + Linux 桌面 VM）且有 secret 需求的人。它的代價是學習曲線最陡——要理解 chezmoi 自己的目錄命名慣例（<code>dot_</code> 前綴代表 <code>.</code> 開頭、<code>private_</code> 前綴代表權限 0600）、template 語法（Go template）、以及「source directory 和目標位置是兩份獨立的檔案」這個心智模型。</p>
<h2 id="選型判讀">選型判讀</h2>
<p>選工具看三個維度：機器數量、OS 組合、secret 需求。</p>
<p>只有一台機器、配置簡單 — bare repo 或 stow 都夠用，差別在於你喜不喜歡 symlink 的管理方式。bare repo 最輕量，stow 多一層模組化。</p>
<p>多台同質機器（都是 macOS 或都是 Linux）— stow。配置檔在同 OS 間差異小，不需要 template，stow 的模組化讓你可以只在桌面機安裝 hyprland package、伺服器只裝 zsh + git + tmux。</p>
<p>多台異質機器（macOS + Linux）但 secret 需求不高 — stow 加上 OS 分流仍然可行，<a href="/blog/linux/dotfile/01-dotfile-management/cross-platform-one-repo/" data-link-title="跨平台共用一個 Repo" data-link-desc="macOS 跟 Linux 要共用同一個 dotfile repo、不想維護兩份時回來讀">跨平台共用一個 Repo</a> 會完整說明做法。</p>
<p>多台異質機器、需要條件安裝但不想學 template 語法 — yadm。alternate files 讓你依 OS/hostname 切換整個配置檔，內建 encrypt 處理 secret，Git 操作方式跟 bare repo 相同。</p>
<p>多台異質機器（macOS + Linux）、有細粒度 template 或密碼管理器整合需求 — chezmoi。檔案內的段落條件、跟 1Password/Bitwarden 的整合、<code>private_</code> 前綴的 permission 管理是它存在的理由。</p>
<p>不確定 — 從 stow 開始。它的概念最直覺（目錄結構 = 安裝後位置）、遷移成本最低（要換到 yadm 是加一層 wrapper、要換到 chezmoi 時目錄結構的概念是相通的）。</p>
]]></content:encoded></item><item><title>模組一：管理工具與目錄結構</title><link>https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/</guid><description>&lt;p>Dotfile 管理的核心動作是把散落在家目錄各處的配置檔集中到一個 Git repo 裡版控。工具只是幫你處理「repo 裡的檔案怎麼對應到家目錄正確位置」這一層映射，選型看的是你的機器數量、OS 組合和 secret 需求。&lt;/p>
&lt;p>開始之前，確認 SSH key 和 Git 已經設好、dotfile repo 已經 clone 到本機——這些前置步驟見&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/setup-order-guide/" data-link-title="環境建置的操作順序" data-link-desc="第一次從零建立 Linux 或 macOS 開發環境、不確定先做什麼後做什麼時讀 — 依賴順序路線圖，每一步附對應模組連結">環境建置的操作順序&lt;/a>的階段一。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/management-strategies/" data-link-title="管理策略與選型" data-link-desc="要選 dotfile 管理工具時回來讀 — bare repo、stow、chezmoi 的適用場景與選型判讀">管理策略與選型&lt;/a>&lt;/td>
 &lt;td>bare repo / stow / chezmoi 三種策略的操作方式、優劣與選型判讀&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/cross-platform-one-repo/" data-link-title="跨平台共用一個 Repo" data-link-desc="macOS 跟 Linux 要共用同一個 dotfile repo、不想維護兩份時回來讀">跨平台共用一個 Repo&lt;/a>&lt;/td>
 &lt;td>macOS + Linux 用同一個 repo 的三層模型：stow 選擇性安裝、OS 分流、local.zsh 機器專屬&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/directory-structure-workflow/" data-link-title="目錄結構、Git 工作流與常見陷阱" data-link-desc="設計 dotfile repo 的目錄結構、或遇到 symlink 衝突和私鑰外洩等問題時回來讀">目錄結構、Git 工作流與常見陷阱&lt;/a>&lt;/td>
 &lt;td>stow 的目錄結構設計原則、日常 Git 操作流程、私鑰外洩等常見陷阱&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/" data-link-title="模組零：Dotfile 心智模型" data-link-desc="換機器、開 VM、重灌系統時需要快速還原開發環境，或想釐清哪些配置該版控、哪些該排除時回來讀">模組零：Dotfile 心智模型&lt;/a>：為什麼要管理、哪些東西該進 repo&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/02-shell-config/" data-link-title="模組二：Shell 配置" data-link-desc="shell 配置檔長成一坨不敢動時回來讀 — .zshrc/.bashrc 的結構化拆分、alias/function/PATH 的模組化設計">模組二：Shell 配置&lt;/a>：目錄結構裡 zsh package 的具體拆法&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/" data-link-title="模組八：同步、Bootstrap 與環境重建" data-link-desc="換機器或重灌時怎麼還原工作環境 — bootstrap script 設計、套件清單管理、跨機器同步策略、secret 排除，以及 VM 快照和 dotfile 重建兩種思路的場景判讀">模組八：同步、Bootstrap 與環境重建&lt;/a>：跨機器同步策略與 secret 管理&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Dotfile 管理的核心動作是把散落在家目錄各處的配置檔集中到一個 Git repo 裡版控。工具只是幫你處理「repo 裡的檔案怎麼對應到家目錄正確位置」這一層映射，選型看的是你的機器數量、OS 組合和 secret 需求。</p>
<p>開始之前，確認 SSH key 和 Git 已經設好、dotfile repo 已經 clone 到本機——這些前置步驟見<a href="/blog/linux/dotfile/00-dotfile-mindset/setup-order-guide/" data-link-title="環境建置的操作順序" data-link-desc="第一次從零建立 Linux 或 macOS 開發環境、不確定先做什麼後做什麼時讀 — 依賴順序路線圖，每一步附對應模組連結">環境建置的操作順序</a>的階段一。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/linux/dotfile/01-dotfile-management/management-strategies/" data-link-title="管理策略與選型" data-link-desc="要選 dotfile 管理工具時回來讀 — bare repo、stow、chezmoi 的適用場景與選型判讀">管理策略與選型</a></td>
          <td>bare repo / stow / chezmoi 三種策略的操作方式、優劣與選型判讀</td>
      </tr>
      <tr>
          <td><a href="/blog/linux/dotfile/01-dotfile-management/cross-platform-one-repo/" data-link-title="跨平台共用一個 Repo" data-link-desc="macOS 跟 Linux 要共用同一個 dotfile repo、不想維護兩份時回來讀">跨平台共用一個 Repo</a></td>
          <td>macOS + Linux 用同一個 repo 的三層模型：stow 選擇性安裝、OS 分流、local.zsh 機器專屬</td>
      </tr>
      <tr>
          <td><a href="/blog/linux/dotfile/01-dotfile-management/directory-structure-workflow/" data-link-title="目錄結構、Git 工作流與常見陷阱" data-link-desc="設計 dotfile repo 的目錄結構、或遇到 symlink 衝突和私鑰外洩等問題時回來讀">目錄結構、Git 工作流與常見陷阱</a></td>
          <td>stow 的目錄結構設計原則、日常 Git 操作流程、私鑰外洩等常見陷阱</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/linux/dotfile/00-dotfile-mindset/" data-link-title="模組零：Dotfile 心智模型" data-link-desc="換機器、開 VM、重灌系統時需要快速還原開發環境，或想釐清哪些配置該版控、哪些該排除時回來讀">模組零：Dotfile 心智模型</a>：為什麼要管理、哪些東西該進 repo</li>
<li>→ <a href="/blog/linux/dotfile/02-shell-config/" data-link-title="模組二：Shell 配置" data-link-desc="shell 配置檔長成一坨不敢動時回來讀 — .zshrc/.bashrc 的結構化拆分、alias/function/PATH 的模組化設計">模組二：Shell 配置</a>：目錄結構裡 zsh package 的具體拆法</li>
<li>→ <a href="/blog/linux/dotfile/08-sync-bootstrap/" data-link-title="模組八：同步、Bootstrap 與環境重建" data-link-desc="換機器或重灌時怎麼還原工作環境 — bootstrap script 設計、套件清單管理、跨機器同步策略、secret 排除，以及 VM 快照和 dotfile 重建兩種思路的場景判讀">模組八：同步、Bootstrap 與環境重建</a>：跨機器同步策略與 secret 管理</li>
</ul>
]]></content:encoded></item><item><title>目錄結構、Git 工作流與常見陷阱</title><link>https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/directory-structure-workflow/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/01-dotfile-management/directory-structure-workflow/</guid><description>&lt;p>不管用哪個工具，dotfile repo 的目錄結構都遵循同一個原則：每個工具（或 package）是一個頂層目錄，內部路徑反映安裝後在家目錄的相對位置。&lt;/p>
&lt;h2 id="目錄結構設計">目錄結構設計&lt;/h2>
&lt;p>以 stow 為例的標準結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">~/dotfiles/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── zsh/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">│ └── .zshrc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── git/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── .gitconfig
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ └── .gitignore_global
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">├── ssh/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ └── .ssh/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">│ └── config
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">├── nvim/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">│ └── .config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">│ └── nvim/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">│ ├── init.lua
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">│ └── lua/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">├── tmux/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">│ └── .config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">│ └── tmux/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">│ └── tmux.conf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">├── hyprland/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">│ └── .config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">│ └── hypr/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">│ └── hyprland.conf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">├── waybar/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">│ └── .config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">│ └── waybar/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">│ ├── config.jsonc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">│ └── style.css
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">├── scripts/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">│ └── install.sh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">├── Brewfile
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">├── packages.txt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">├── .gitignore
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">└── README.md&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這些設計選擇的理由：&lt;/p>
&lt;p>&lt;strong>每個工具一個頂層目錄&lt;/strong>。stow 的 package 概念讓你可以選擇性安裝——伺服器不需要 hyprland 和 waybar，只 stow 需要的 package。即使不用 stow，這個分法也讓 repo 結構清晰：看頂層目錄就知道管了哪些工具。&lt;/p>
&lt;p>&lt;strong>目錄內路徑映射安裝位置&lt;/strong>。&lt;code>nvim/.config/nvim/init.lua&lt;/code> 安裝後變成 &lt;code>~/.config/nvim/init.lua&lt;/code>。這個映射是 stow 的核心假設，但即使用 chezmoi 或 bare repo，維持同樣的思維讓目錄結構自解釋。&lt;/p>
&lt;p>&lt;strong>scripts/ 不是 stow package&lt;/strong>。&lt;code>scripts/install.sh&lt;/code> 是 bootstrap 用的安裝腳本，不應該被 stow 到家目錄。它放在 repo 裡是為了讓新機器還原時有一個入口點可以跑。&lt;/p>
&lt;p>&lt;strong>Brewfile / packages.txt 記錄套件清單&lt;/strong>。配置檔只告訴工具「怎麼用」，但前提是工具已安裝。&lt;code>Brewfile&lt;/code>（macOS 用 &lt;code>brew bundle&lt;/code>）和 &lt;code>packages.txt&lt;/code>（Linux 用套件管理器批次安裝）把「裝了什麼」也納入版控，讓新機器還原時不用靠記憶。&lt;/p>
&lt;p>&lt;strong>ssh/ 只放 config，不放私鑰&lt;/strong>。&lt;code>~/.ssh/config&lt;/code> 記錄 SSH 連線設定（Host alias、ProxyJump 等），是有版控價值的配置。私鑰（&lt;code>id_ed25519&lt;/code>、&lt;code>id_rsa&lt;/code>）和公鑰不應進 dotfile repo，即使 repo 是 private。私鑰用密碼管理器或機器本地生成。&lt;/p>
&lt;h2 id="git-工作流">Git 工作流&lt;/h2>
&lt;p>dotfile repo 的 Git 工作流比一般程式碼專案簡單，因為通常只有一個人在用，branch 和 PR 的需求低。&lt;/p>
&lt;p>&lt;strong>日常修改&lt;/strong>。直接編輯配置（symlink 透通到 repo 裡的實體檔案），然後 commit：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/dotfiles
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git add zsh/.zshrc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;zsh: add fzf integration&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">git push&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>新增一個工具的配置&lt;/strong>。先在 dotfiles 建好目錄結構，把現有配置搬進去，建 symlink，然後 commit：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p ~/dotfiles/alacritty/.config/alacritty
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">mv ~/.config/alacritty/alacritty.toml ~/dotfiles/alacritty/.config/alacritty/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> ~/dotfiles &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> stow alacritty
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">git add alacritty/ &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> git commit -m &lt;span class="s2">&amp;#34;add alacritty config&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>新機器還原&lt;/strong>。整個流程應該能在幾分鐘內完成：&lt;/p></description><content:encoded><![CDATA[<p>不管用哪個工具，dotfile repo 的目錄結構都遵循同一個原則：每個工具（或 package）是一個頂層目錄，內部路徑反映安裝後在家目錄的相對位置。</p>
<h2 id="目錄結構設計">目錄結構設計</h2>
<p>以 stow 為例的標準結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">~/dotfiles/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── zsh/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   └── .zshrc
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── git/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── .gitconfig
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   └── .gitignore_global
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">├── ssh/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── .ssh/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│       └── config
</span></span><span class="line"><span class="ln">10</span><span class="cl">├── nvim/
</span></span><span class="line"><span class="ln">11</span><span class="cl">│   └── .config/
</span></span><span class="line"><span class="ln">12</span><span class="cl">│       └── nvim/
</span></span><span class="line"><span class="ln">13</span><span class="cl">│           ├── init.lua
</span></span><span class="line"><span class="ln">14</span><span class="cl">│           └── lua/
</span></span><span class="line"><span class="ln">15</span><span class="cl">├── tmux/
</span></span><span class="line"><span class="ln">16</span><span class="cl">│   └── .config/
</span></span><span class="line"><span class="ln">17</span><span class="cl">│       └── tmux/
</span></span><span class="line"><span class="ln">18</span><span class="cl">│           └── tmux.conf
</span></span><span class="line"><span class="ln">19</span><span class="cl">├── hyprland/
</span></span><span class="line"><span class="ln">20</span><span class="cl">│   └── .config/
</span></span><span class="line"><span class="ln">21</span><span class="cl">│       └── hypr/
</span></span><span class="line"><span class="ln">22</span><span class="cl">│           └── hyprland.conf
</span></span><span class="line"><span class="ln">23</span><span class="cl">├── waybar/
</span></span><span class="line"><span class="ln">24</span><span class="cl">│   └── .config/
</span></span><span class="line"><span class="ln">25</span><span class="cl">│       └── waybar/
</span></span><span class="line"><span class="ln">26</span><span class="cl">│           ├── config.jsonc
</span></span><span class="line"><span class="ln">27</span><span class="cl">│           └── style.css
</span></span><span class="line"><span class="ln">28</span><span class="cl">├── scripts/
</span></span><span class="line"><span class="ln">29</span><span class="cl">│   └── install.sh
</span></span><span class="line"><span class="ln">30</span><span class="cl">├── Brewfile
</span></span><span class="line"><span class="ln">31</span><span class="cl">├── packages.txt
</span></span><span class="line"><span class="ln">32</span><span class="cl">├── .gitignore
</span></span><span class="line"><span class="ln">33</span><span class="cl">└── README.md</span></span></code></pre></div><p>這些設計選擇的理由：</p>
<p><strong>每個工具一個頂層目錄</strong>。stow 的 package 概念讓你可以選擇性安裝——伺服器不需要 hyprland 和 waybar，只 stow 需要的 package。即使不用 stow，這個分法也讓 repo 結構清晰：看頂層目錄就知道管了哪些工具。</p>
<p><strong>目錄內路徑映射安裝位置</strong>。<code>nvim/.config/nvim/init.lua</code> 安裝後變成 <code>~/.config/nvim/init.lua</code>。這個映射是 stow 的核心假設，但即使用 chezmoi 或 bare repo，維持同樣的思維讓目錄結構自解釋。</p>
<p><strong>scripts/ 不是 stow package</strong>。<code>scripts/install.sh</code> 是 bootstrap 用的安裝腳本，不應該被 stow 到家目錄。它放在 repo 裡是為了讓新機器還原時有一個入口點可以跑。</p>
<p><strong>Brewfile / packages.txt 記錄套件清單</strong>。配置檔只告訴工具「怎麼用」，但前提是工具已安裝。<code>Brewfile</code>（macOS 用 <code>brew bundle</code>）和 <code>packages.txt</code>（Linux 用套件管理器批次安裝）把「裝了什麼」也納入版控，讓新機器還原時不用靠記憶。</p>
<p><strong>ssh/ 只放 config，不放私鑰</strong>。<code>~/.ssh/config</code> 記錄 SSH 連線設定（Host alias、ProxyJump 等），是有版控價值的配置。私鑰（<code>id_ed25519</code>、<code>id_rsa</code>）和公鑰不應進 dotfile repo，即使 repo 是 private。私鑰用密碼管理器或機器本地生成。</p>
<h2 id="git-工作流">Git 工作流</h2>
<p>dotfile repo 的 Git 工作流比一般程式碼專案簡單，因為通常只有一個人在用，branch 和 PR 的需求低。</p>
<p><strong>日常修改</strong>。直接編輯配置（symlink 透通到 repo 裡的實體檔案），然後 commit：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> ~/dotfiles
</span></span><span class="line"><span class="ln">2</span><span class="cl">git add zsh/.zshrc
</span></span><span class="line"><span class="ln">3</span><span class="cl">git commit -m <span class="s2">&#34;zsh: add fzf integration&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">git push</span></span></code></pre></div><p><strong>新增一個工具的配置</strong>。先在 dotfiles 建好目錄結構，把現有配置搬進去，建 symlink，然後 commit：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p ~/dotfiles/alacritty/.config/alacritty
</span></span><span class="line"><span class="ln">2</span><span class="cl">mv ~/.config/alacritty/alacritty.toml ~/dotfiles/alacritty/.config/alacritty/
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">cd</span> ~/dotfiles <span class="o">&amp;&amp;</span> stow alacritty
</span></span><span class="line"><span class="ln">4</span><span class="cl">git add alacritty/ <span class="o">&amp;&amp;</span> git commit -m <span class="s2">&#34;add alacritty config&#34;</span></span></span></code></pre></div><p><strong>新機器還原</strong>。整個流程應該能在幾分鐘內完成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 1. 裝 Git 和 stow（通常是最先裝的兩個東西）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 2. clone</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">git clone git@github.com:you/dotfiles.git ~/dotfiles
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 3. 安裝套件</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nb">cd</span> ~/dotfiles
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">brew bundle          <span class="c1"># macOS</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 或</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">xargs sudo pacman -S &lt; packages.txt  <span class="c1"># Arch</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 4. 建 symlink</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">stow zsh git nvim tmux ssh
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 5. 重開 shell，配置生效</span></span></span></code></pre></div><h2 id="常見陷阱">常見陷阱</h2>
<p><strong>私鑰進 repo</strong>。把 <code>~/.ssh/</code> 整個目錄（含 <code>id_ed25519</code>）推上 GitHub 是最危險的錯誤。即使事後刪除，Git 歷史裡仍然留有私鑰。做法是只追蹤 <code>~/.ssh/config</code>，在 <code>.gitignore</code> 明確排除 <code>*.pem</code>、<code>id_*</code>。</p>
<p><strong>缺少 .gitignore</strong>。很多工具會在配置目錄產生 cache、compiled 檔案、session 狀態。nvim 的 <code>plugin/packer_compiled.lua</code>、zsh 的 <code>.zcompdump</code>、tmux 的 <code>resurrect/</code> 都不該進 repo。建 repo 時第一件事就是寫 <code>.gitignore</code>。</p>
<p><strong>symlink 衝突</strong>。<code>stow zsh</code> 時如果 <code>~/.zshrc</code> 已經存在且不是 symlink，stow 會拒絕操作。解法是先備份再安裝：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mv ~/.zshrc ~/.zshrc.bak
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> ~/dotfiles <span class="o">&amp;&amp;</span> stow zsh</span></span></code></pre></div><p><strong>路徑寫死</strong>。<code>.zshrc</code> 裡寫 <code>source /Users/mac-eric/.nvm/nvm.sh</code> 搬到 Linux 就壞了。改用 <code>$HOME</code>：<code>source &quot;$HOME/.nvm/nvm.sh&quot;</code>。配置檔裡每一處絕對路徑都是可攜性的隱患。</p>
<p><strong>整包 .config 放一個 package</strong>。把 <code>~/.config</code> 整個目錄當成一個 stow package 會喪失模組化的好處，而且衝突風險大幅增加。正確做法是每個工具拆開：nvim 一個、tmux 一個、hyprland 一個。</p>
]]></content:encoded></item><item><title>斷網環境的版本控制與 CI/CD</title><link>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-vcs-ci/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-vcs-ci/</guid><description>&lt;p>版本控制和 CI/CD 是所有 infra 操作的前提——程式碼要有地方存、變更要能被 review、build 和 deploy 要自動化。正常環境裡這些由 GitHub + GitHub Actions 提供，斷網環境裡這兩個服務都不存在，需要在內網自建替代品。&lt;/p>
&lt;h2 id="gitlab-ce-vs-gitea選型判準">GitLab CE vs Gitea：選型判準&lt;/h2>
&lt;p>兩個主流的自建版本控制方案定位不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>GitLab CE&lt;/th>
 &lt;th>Gitea&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>定位&lt;/td>
 &lt;td>VCS + CI + Container Registry + Issue Tracker 一體&lt;/td>
 &lt;td>純 VCS（輕量 Git 伺服器）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資源需求&lt;/td>
 &lt;td>4GB+ RAM、推薦 8GB&lt;/td>
 &lt;td>512MB RAM 即可運作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI 內建&lt;/td>
 &lt;td>GitLab CI（&lt;code>.gitlab-ci.yml&lt;/code>）&lt;/td>
 &lt;td>無（搭配 Drone / Woodpecker / Jenkins）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Container Registry&lt;/td>
 &lt;td>內建&lt;/td>
 &lt;td>無（搭配 Harbor）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安裝複雜度&lt;/td>
 &lt;td>中（Omnibus 包裝簡化了安裝、但設定項多）&lt;/td>
 &lt;td>低（單一二進位檔、啟動即可用）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>維護負擔&lt;/td>
 &lt;td>高（PostgreSQL、Redis、Sidekiq 都在裡面）&lt;/td>
 &lt;td>低（SQLite 或 MySQL、無背景服務）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選型判準是團隊規模和需要的功能範圍。5 人以下、只需要 VCS + 輕量 CI 的團隊，Gitea + Drone 的組合維護成本低。10 人以上、需要 MR review + CI pipeline + Container Registry 一站到位的團隊，GitLab CE 的整合度值得它的資源消耗。&lt;/p>
&lt;p>接下來以 GitLab CE 為主線說明（功能最完整），Gitea 的差異在各段附註。&lt;/p>
&lt;h2 id="gitlab-ce-離線安裝">GitLab CE 離線安裝&lt;/h2>
&lt;p>GitLab Omnibus 包把所有依賴打包成單一安裝檔，不需要在目標機器上 &lt;code>apt install&lt;/code> 任何前置套件。&lt;/p>
&lt;h3 id="在外網機器下載安裝包">在外網機器下載安裝包&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/ubuntu/jammy/gitlab-ce_17.0.0-ce.0_amd64.deb/download.deb
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># RHEL/CentOS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/el/9/gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm/download.rpm&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把下載的 &lt;code>.deb&lt;/code> 或 &lt;code>.rpm&lt;/code> 透過&lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">內容搬運機制&lt;/a>（USB、光碟、跨邊界傳輸站）帶進斷網環境。&lt;/p>
&lt;h3 id="在斷網機器安裝">在斷網機器安裝&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sudo dpkg -i gitlab-ce_17.0.0-ce.0_amd64.deb
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># RHEL/CentOS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">sudo yum localinstall gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="離線設定">離線設定&lt;/h3>
&lt;p>安裝後編輯 &lt;code>/etc/gitlab/gitlab.rb&lt;/code>，把所有外部連線關掉：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ruby" data-lang="ruby">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 設定內部域名（不是公網域名）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n">external_url&lt;/span> &lt;span class="s1">&amp;#39;https://gitlab.internal.example.com&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 關閉 Gravatar（頭像服務、需要外網）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="n">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;gravatar_enabled&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 關閉 usage ping（回報使用統計到 GitLab Inc）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="n">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;usage_ping_enabled&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1"># 關閉 version check&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="n">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;gitlab_check_on_connect&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1"># 如果沒有內部 SMTP，用 sendmail 或關閉 email&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n">gitlab_rails&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;smtp_enable&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kp">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="c1"># TLS 憑證用內部 CA 簽發&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="n">nginx&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;ssl_certificate&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/etc/gitlab/ssl/gitlab.crt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="n">nginx&lt;/span>&lt;span class="o">[&lt;/span>&lt;span class="s1">&amp;#39;ssl_certificate_key&amp;#39;&lt;/span>&lt;span class="o">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/etc/gitlab/ssl/gitlab.key&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sudo gitlab-ctl reconfigure&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Gitea 的離線安裝更簡單：下載單一二進位檔 &lt;code>gitea&lt;/code>、設定 &lt;code>app.ini&lt;/code>、用 systemd 管理即可。&lt;/p>
&lt;h3 id="升級策略">升級策略&lt;/h3>
&lt;p>GitLab CE 的升級包也要從外部下載帶進來。升級前先備份（&lt;code>gitlab-backup create&lt;/code>），升級路徑要按 GitLab 的&lt;a href="https://docs.gitlab.com/ee/update/index.html#upgrade-paths">版本跳級規則&lt;/a>——不能任意跳版、某些大版本之間需要中繼版本。在斷網環境裡，每次升級要預先規劃中繼版本、一次帶進所有需要的安裝包。&lt;/p></description><content:encoded><![CDATA[<p>版本控制和 CI/CD 是所有 infra 操作的前提——程式碼要有地方存、變更要能被 review、build 和 deploy 要自動化。正常環境裡這些由 GitHub + GitHub Actions 提供，斷網環境裡這兩個服務都不存在，需要在內網自建替代品。</p>
<h2 id="gitlab-ce-vs-gitea選型判準">GitLab CE vs Gitea：選型判準</h2>
<p>兩個主流的自建版本控制方案定位不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>GitLab CE</th>
          <th>Gitea</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>定位</td>
          <td>VCS + CI + Container Registry + Issue Tracker 一體</td>
          <td>純 VCS（輕量 Git 伺服器）</td>
      </tr>
      <tr>
          <td>資源需求</td>
          <td>4GB+ RAM、推薦 8GB</td>
          <td>512MB RAM 即可運作</td>
      </tr>
      <tr>
          <td>CI 內建</td>
          <td>GitLab CI（<code>.gitlab-ci.yml</code>）</td>
          <td>無（搭配 Drone / Woodpecker / Jenkins）</td>
      </tr>
      <tr>
          <td>Container Registry</td>
          <td>內建</td>
          <td>無（搭配 Harbor）</td>
      </tr>
      <tr>
          <td>安裝複雜度</td>
          <td>中（Omnibus 包裝簡化了安裝、但設定項多）</td>
          <td>低（單一二進位檔、啟動即可用）</td>
      </tr>
      <tr>
          <td>維護負擔</td>
          <td>高（PostgreSQL、Redis、Sidekiq 都在裡面）</td>
          <td>低（SQLite 或 MySQL、無背景服務）</td>
      </tr>
  </tbody>
</table>
<p>選型判準是團隊規模和需要的功能範圍。5 人以下、只需要 VCS + 輕量 CI 的團隊，Gitea + Drone 的組合維護成本低。10 人以上、需要 MR review + CI pipeline + Container Registry 一站到位的團隊，GitLab CE 的整合度值得它的資源消耗。</p>
<p>接下來以 GitLab CE 為主線說明（功能最完整），Gitea 的差異在各段附註。</p>
<h2 id="gitlab-ce-離線安裝">GitLab CE 離線安裝</h2>
<p>GitLab Omnibus 包把所有依賴打包成單一安裝檔，不需要在目標機器上 <code>apt install</code> 任何前置套件。</p>
<h3 id="在外網機器下載安裝包">在外網機器下載安裝包</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/ubuntu/jammy/gitlab-ce_17.0.0-ce.0_amd64.deb/download.deb
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># RHEL/CentOS</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">wget https://packages.gitlab.com/gitlab/gitlab-ce/packages/el/9/gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm/download.rpm</span></span></code></pre></div><p>把下載的 <code>.deb</code> 或 <code>.rpm</code> 透過<a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">內容搬運機制</a>（USB、光碟、跨邊界傳輸站）帶進斷網環境。</p>
<h3 id="在斷網機器安裝">在斷網機器安裝</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo dpkg -i gitlab-ce_17.0.0-ce.0_amd64.deb
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># RHEL/CentOS</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo yum localinstall gitlab-ce-17.0.0-ce.0.el9.x86_64.rpm</span></span></code></pre></div><h3 id="離線設定">離線設定</h3>
<p>安裝後編輯 <code>/etc/gitlab/gitlab.rb</code>，把所有外部連線關掉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 設定內部域名（不是公網域名）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">external_url</span> <span class="s1">&#39;https://gitlab.internal.example.com&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 關閉 Gravatar（頭像服務、需要外網）</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;gravatar_enabled&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 關閉 usage ping（回報使用統計到 GitLab Inc）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;usage_ping_enabled&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 關閉 version check</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;gitlab_check_on_connect&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 如果沒有內部 SMTP，用 sendmail 或關閉 email</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">gitlab_rails</span><span class="o">[</span><span class="s1">&#39;smtp_enable&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="kp">false</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"># TLS 憑證用內部 CA 簽發</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">nginx</span><span class="o">[</span><span class="s1">&#39;ssl_certificate&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="s2">&#34;/etc/gitlab/ssl/gitlab.crt&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">nginx</span><span class="o">[</span><span class="s1">&#39;ssl_certificate_key&#39;</span><span class="o">]</span> <span class="o">=</span> <span class="s2">&#34;/etc/gitlab/ssl/gitlab.key&#34;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sudo gitlab-ctl reconfigure</span></span></code></pre></div><p>Gitea 的離線安裝更簡單：下載單一二進位檔 <code>gitea</code>、設定 <code>app.ini</code>、用 systemd 管理即可。</p>
<h3 id="升級策略">升級策略</h3>
<p>GitLab CE 的升級包也要從外部下載帶進來。升級前先備份（<code>gitlab-backup create</code>），升級路徑要按 GitLab 的<a href="https://docs.gitlab.com/ee/update/index.html#upgrade-paths">版本跳級規則</a>——不能任意跳版、某些大版本之間需要中繼版本。在斷網環境裡，每次升級要預先規劃中繼版本、一次帶進所有需要的安裝包。</p>
<h2 id="ci-runner-離線設定">CI Runner 離線設定</h2>
<p>CI pipeline 在斷網環境裡跑的最大差異是 runner 不能即時拉依賴。</p>
<h3 id="runner-安裝與註冊">Runner 安裝與註冊</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 下載 runner 二進位檔（外網下載、帶進來）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># https://docs.gitlab.com/runner/install/linux-manually.html</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">sudo gitlab-runner register <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  --url https://gitlab.internal.example.com <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --token <span class="nv">$RUNNER_TOKEN</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --executor docker <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --docker-image alpine:3.20</span></span></code></pre></div><h3 id="executor-選擇">Executor 選擇</h3>
<table>
  <thead>
      <tr>
          <th>Executor</th>
          <th>隔離性</th>
          <th>前置條件</th>
          <th>斷網適用度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>shell</td>
          <td>低（直接跑在 runner 機器上）</td>
          <td>無</td>
          <td>高（最簡單）</td>
      </tr>
      <tr>
          <td>docker</td>
          <td>高（每個 job 一個容器）</td>
          <td>需要 Docker + 預拉 image</td>
          <td>中（image 管理成本）</td>
      </tr>
      <tr>
          <td>kubernetes</td>
          <td>高（每個 job 一個 pod）</td>
          <td>需要 K8s cluster</td>
          <td>低（斷網 K8s 維護重）</td>
      </tr>
  </tbody>
</table>
<p>斷網環境推薦 shell executor（最少依賴）或 docker executor 搭配預拉好的 image。</p>
<h3 id="docker-executor-的-image-管理">Docker executor 的 image 管理</h3>
<p>Docker executor 的每個 job 都基於一個 base image。斷網環境裡這些 image 必須預先存在於內網的 <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">private registry</a>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># runner 的 /etc/docker/daemon.json 指向內部 registry</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="s2">&#34;insecure-registries&#34;</span>: <span class="o">[</span><span class="s2">&#34;registry.internal:5000&#34;</span><span class="o">]</span>,
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="s2">&#34;registry-mirrors&#34;</span>: <span class="o">[</span><span class="s2">&#34;https://registry.internal:5000&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>CI pipeline 裡用到的每個 image（build 用的 golang/node/php、lint 用的 tflint/checkov、deploy 用的 awscli）都要事先搬進內部 registry。</p>
<h3 id="依賴快取">依賴快取</h3>
<p>沒有 npm registry / PyPI / Maven Central 可以拉，CI job 的依賴安裝必須用本地來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># .gitlab-ci.yml — 使用內部 Nexus 作為套件來源</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">variables</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">NPM_CONFIG_REGISTRY</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://nexus.internal/repository/npm-proxy/&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">PIP_INDEX_URL</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://nexus.internal/repository/pypi-proxy/simple/&#34;</span></span></span></code></pre></div><p>或者把 <code>node_modules</code> / <code>vendor</code> 打包成 CI artifact 快取，避免每次 job 都重新安裝。</p>
<h2 id="git-bundle-跨邊界傳輸">Git Bundle 跨邊界傳輸</h2>
<p>某些斷網環境不允許直接 <code>git push</code> 到內網 GitLab（例如開發在外網、部署在內網）。Git bundle 是把 commit 歷史打包成單一檔案的機制：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 外網開發機：打包最近的 commit</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git bundle create changes.bundle main~5..main
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 帶進斷網環境後</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git bundle verify changes.bundle
</span></span><span class="line"><span class="ln">6</span><span class="cl">git fetch changes.bundle main:incoming
</span></span><span class="line"><span class="ln">7</span><span class="cl">git merge incoming</span></span></code></pre></div><p>bundle 檔案包含完整的 Git 物件（commit、tree、blob），可以通過任何檔案傳輸方式帶過邊界——USB、光碟、審批後的檔案傳輸閘道。</p>
<p>跨邊界傳輸的安全考量：bundle 的內容應該在傳入前被掃描（至少 <code>git bundle verify</code>），確認不包含預期外的分支或異常大的物件。某些高安全環境要求所有跨邊界檔案經過人工審批。</p>
<h2 id="mr-review-流程">MR Review 流程</h2>
<p>斷網環境的 MR（Merge Request）review 流程跟<a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>的原則相同——變更走 MR → CI 跑 plan → reviewer 看 diff + plan 輸出 → 合併 → apply。差別在於所有環節都在內網：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .gitlab-ci.yml — Terraform plan 貼回 MR comment</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">plan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">stage</span><span class="p">:</span><span class="w"> </span><span class="l">plan</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">script</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span>- <span class="l">terraform init -plugin-dir=/opt/terraform/plugins</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span>- <span class="l">terraform plan -no-color -out=plan.tfplan | tee plan.txt</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span>- <span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sd">      curl --request POST \
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sd">        --header &#34;PRIVATE-TOKEN: $GITLAB_TOKEN&#34; \
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">        --data-urlencode &#34;body=$(cat plan.txt)&#34; \
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">        &#34;https://gitlab.internal/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="nt">only</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span>- <span class="l">merge_requests</span></span></span></code></pre></div><p>GitLab CI 的 <code>merge_requests</code> trigger 跟 GitHub Actions 的 <code>pull_request</code> 等價——MR 開啟或更新時自動跑 pipeline。</p>
<p>reviewer 在 GitLab 的 MR 頁面看 code diff + plan 輸出 comment，approve 後合併，合併觸發 apply pipeline。流程跟有網路時完全相同，只是所有元件（GitLab、runner、Terraform、provider plugin）都在內網。</p>
<h2 id="時程與維護">時程與維護</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>初始設定</th>
          <th>持續維護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GitLab CE 安裝 + 設定</td>
          <td>1 天</td>
          <td>每季升級（含帶包 + 備份 + 升級 + 驗證）~半天</td>
      </tr>
      <tr>
          <td>CI runner 設定</td>
          <td>半天</td>
          <td>image 更新隨 registry 同步</td>
      </tr>
      <tr>
          <td>Gitea + Drone（替代方案）</td>
          <td>半天</td>
          <td>極低（二進位更新即可）</td>
      </tr>
      <tr>
          <td>Git bundle 流程建立</td>
          <td>2 小時</td>
          <td>按需（有跨邊界需求時）</td>
      </tr>
  </tbody>
</table>
<p>GitLab CE 的主要維護成本在升級——斷網環境的升級不能一鍵 <code>apt upgrade</code>，要預先下載正確版本的安裝包帶進來。跳版規則讓這個過程比正常環境多一層規劃。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-principles/" data-link-title="斷網環境的通用原則" data-link-desc="離線套件管理、內容搬運、變更追蹤的共通操作模式 — 所有斷網情境都要先建立的基礎能力">斷網環境的通用原則</a>：內容搬運、離線套件管理的共通模式</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-container/" data-link-title="斷網環境的容器與映像管理" data-link-desc="Private registry 架設、映像搬運（docker save/load、skopeo）、base image 更新週期、離線漏洞掃描">斷網環境的容器與映像管理</a>：CI runner 的 Docker image 管理</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：MR review 流程的原則與護欄</li>
</ul>
]]></content:encoded></item><item><title>4.10 衍生產物管理原理：什麼進 git、什麼不該</title><link>https://tarrragon.github.io/blog/llm/04-applications/artifact-management/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/artifact-management/</guid><description>&lt;p>LLM 應用的 codebase 不只 source code、還含 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding&lt;/a> index、cache、model weights、prompt config、lockfile、log 等各種「衍生」或「外部」產物。每個產物該不該進 git、有沒有共通邏輯？&lt;/p>
&lt;p>本章寫的是「&lt;strong>source / derived / external 三類產物的判讀框架&lt;/strong>」、跟「production deployment 怎麼處理 share + reproducibility 取捨」。對應到 hands-on 系列實際遇到的問題——為什麼 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/rag/" data-link-title="RAG" data-link-desc="Retrieval-Augmented Generation：動態外掛知識給 LLM、繞開模型參數記憶的靜態限制">RAG&lt;/a> demo 的 &lt;code>index.pkl&lt;/code> 進 &lt;code>.gitignore&lt;/code>、Hugging Face model weights 為什麼不能塞進 repo、prompt template 該怎麼版本管理。&lt;/p>
&lt;p>跟 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/production-resource-planning/" data-link-title="4.9 Production 部署的資源評估原理" data-link-desc="從本地單 user 到 production multi-tenant：concurrent users、cost model、observability、SLA、capacity planning 的設計取捨">4.9 Production resource planning&lt;/a> 對應「production 怎麼跑」、本章對應「production 怎麼版本控制 + 部署」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後你能：&lt;/p>
&lt;ol>
&lt;li>用「source / derived / external」三分類判讀任何產物該不該進 git。&lt;/li>
&lt;li>看到 &lt;code>.gitignore&lt;/code> 設計、能解釋每條規則的邏輯。&lt;/li>
&lt;li>在 reproducibility 跟 repo 大小之間做合理取捨。&lt;/li>
&lt;li>知道 derived / external 產物該用什麼機制 share（registry、build script、artifact storage）。&lt;/li>
&lt;/ol>
&lt;h2 id="三類產物-framework">三類產物 framework&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類別&lt;/th>
 &lt;th>定義&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;th>該進 git？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Source&lt;/strong>&lt;/td>
 &lt;td>人類撰寫、是真理來源&lt;/td>
 &lt;td>code、prompt template、test fixture、config schema&lt;/td>
 &lt;td>必須&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Derived&lt;/strong>&lt;/td>
 &lt;td>從 source 自動產出、可重建&lt;/td>
 &lt;td>binary、index、cache、compiled output、generated docs&lt;/td>
 &lt;td>不該&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>External&lt;/strong>&lt;/td>
 &lt;td>從外部下載、跟 source 解耦&lt;/td>
 &lt;td>model weights、dependency package、dataset&lt;/td>
 &lt;td>用 registry / manifest&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>判讀問題：「&lt;strong>刪掉重來、用什麼能 reconstruct 一模一樣？&lt;/strong>」&lt;/p>
&lt;ul>
&lt;li>用人手寫 → source、必須 commit&lt;/li>
&lt;li>用 build script + source → derived、commit manifest（如 lockfile）不 commit output&lt;/li>
&lt;li>用 download script + URL → external、commit URL 不 commit content&lt;/li>
&lt;/ul>
&lt;p>這個 framework 跨任何技術 stack 都成立（不只 LLM）、但 LLM 應用尤其放大 derived / external 比例。&lt;/p>
&lt;h2 id="llm-應用具體對應">LLM 應用具體對應&lt;/h2>
&lt;h3 id="source進-git">Source（進 git）&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>產物&lt;/th>
 &lt;th>說明&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>程式 source code&lt;/td>
 &lt;td>wrapper script、framework 整合 code&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Prompt template&lt;/td>
 &lt;td>system prompt、few-shot example、prompt structure&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Config schema&lt;/td>
 &lt;td>哪些參數可調、合法範圍、default value&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Test fixture&lt;/td>
 &lt;td>測試輸入 / 預期輸出 pair&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Markdown content（如本 blog）&lt;/td>
 &lt;td>文章本身就是 source&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.gitignore&lt;/code> / lock file 規則&lt;/td>
 &lt;td>描述哪些不進 git 也是 source&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build script&lt;/td>
 &lt;td>&lt;code>ingest.py&lt;/code>、&lt;code>build.sh&lt;/code>、能從 source 重建 derived&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="derived不進-git但-build-path-進-git">Derived（不進 git、但 build path 進 git）&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>產物&lt;/th>
 &lt;th>為什麼不 commit&lt;/th>
 &lt;th>怎麼 share&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>index.pkl&lt;/code>（RAG embedding index）&lt;/td>
 &lt;td>從 corpus + embedding model 重建、跟 model 版本綁、3.7 MB-GB 級&lt;/td>
 &lt;td>&lt;code>ingest.py&lt;/code> script、跑一次就 reconstruct&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Embedding cache（per-document hash）&lt;/td>
 &lt;td>跑時動態建、避免重 embed 同 chunk&lt;/td>
 &lt;td>不 share、各自 rebuild&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python &lt;code>__pycache__/&lt;/code>&lt;/td>
 &lt;td>跑時自動產、Python 版本敏感&lt;/td>
 &lt;td>不 share、各自 rebuild&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Compiled binary（如 &lt;code>bin/mdtools&lt;/code>）&lt;/td>
 &lt;td>從 Go source build、平台敏感&lt;/td>
 &lt;td>source + build instructions、可選 release page 提供&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Generated docs（如 Hugo &lt;code>public/&lt;/code>）&lt;/td>
 &lt;td>從 markdown source build、deploy 時自動生&lt;/td>
 &lt;td>source + deploy pipeline&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Log files&lt;/td>
 &lt;td>runtime output、量大、有 PII 風險&lt;/td>
 &lt;td>不 share、log retention 政策另立&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="external不進-git用-manifest--registry">External（不進 git、用 manifest / registry）&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>產物&lt;/th>
 &lt;th>Manifest / registry&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>LLM model weights&lt;/td>
 &lt;td>Hugging Face / Ollama registry tag&lt;/td>
 &lt;td>&lt;code>nomic-embed-text:latest&lt;/code>、&lt;code>sd_xl_base_1.0&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Python dependency&lt;/td>
 &lt;td>&lt;code>requirements.txt&lt;/code> / &lt;code>pyproject.toml&lt;/code>&lt;/td>
 &lt;td>&lt;code>requests==2.31.0&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Node modules&lt;/td>
 &lt;td>&lt;code>package.json&lt;/code> + &lt;code>package-lock.json&lt;/code>&lt;/td>
 &lt;td>&lt;code>react@18.2.0&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Dataset&lt;/td>
 &lt;td>&lt;code>data.dvc&lt;/code> / S3 URL + checksum&lt;/td>
 &lt;td>training data、eval set&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Docker image&lt;/td>
 &lt;td>&lt;code>Dockerfile&lt;/code> + image tag&lt;/td>
 &lt;td>&lt;code>python:3.11-slim&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>External 跟 derived 的差別：external 來自 git 外的 source、derived 來自 git 內的 source。&lt;strong>機制上都用同套路徑&lt;/strong>——manifest 進 git、實際 bytes 存 registry、避免大檔直接進 commit history。&lt;/p></description><content:encoded><![CDATA[<p>LLM 應用的 codebase 不只 source code、還含 <a href="/blog/llm/knowledge-cards/embedding-model/" data-link-title="Embedding Model" data-link-desc="把文字轉成向量的模型：用於 codebase 索引與語意搜尋">embedding</a> index、cache、model weights、prompt config、lockfile、log 等各種「衍生」或「外部」產物。每個產物該不該進 git、有沒有共通邏輯？</p>
<p>本章寫的是「<strong>source / derived / external 三類產物的判讀框架</strong>」、跟「production deployment 怎麼處理 share + reproducibility 取捨」。對應到 hands-on 系列實際遇到的問題——為什麼 <a href="/blog/llm/knowledge-cards/rag/" data-link-title="RAG" data-link-desc="Retrieval-Augmented Generation：動態外掛知識給 LLM、繞開模型參數記憶的靜態限制">RAG</a> demo 的 <code>index.pkl</code> 進 <code>.gitignore</code>、Hugging Face model weights 為什麼不能塞進 repo、prompt template 該怎麼版本管理。</p>
<p>跟 <a href="/blog/llm/04-applications/production-resource-planning/" data-link-title="4.9 Production 部署的資源評估原理" data-link-desc="從本地單 user 到 production multi-tenant：concurrent users、cost model、observability、SLA、capacity planning 的設計取捨">4.9 Production resource planning</a> 對應「production 怎麼跑」、本章對應「production 怎麼版本控制 + 部署」。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後你能：</p>
<ol>
<li>用「source / derived / external」三分類判讀任何產物該不該進 git。</li>
<li>看到 <code>.gitignore</code> 設計、能解釋每條規則的邏輯。</li>
<li>在 reproducibility 跟 repo 大小之間做合理取捨。</li>
<li>知道 derived / external 產物該用什麼機制 share（registry、build script、artifact storage）。</li>
</ol>
<h2 id="三類產物-framework">三類產物 framework</h2>
<table>
  <thead>
      <tr>
          <th>類別</th>
          <th>定義</th>
          <th>例子</th>
          <th>該進 git？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Source</strong></td>
          <td>人類撰寫、是真理來源</td>
          <td>code、prompt template、test fixture、config schema</td>
          <td>必須</td>
      </tr>
      <tr>
          <td><strong>Derived</strong></td>
          <td>從 source 自動產出、可重建</td>
          <td>binary、index、cache、compiled output、generated docs</td>
          <td>不該</td>
      </tr>
      <tr>
          <td><strong>External</strong></td>
          <td>從外部下載、跟 source 解耦</td>
          <td>model weights、dependency package、dataset</td>
          <td>用 registry / manifest</td>
      </tr>
  </tbody>
</table>
<p>判讀問題：「<strong>刪掉重來、用什麼能 reconstruct 一模一樣？</strong>」</p>
<ul>
<li>用人手寫 → source、必須 commit</li>
<li>用 build script + source → derived、commit manifest（如 lockfile）不 commit output</li>
<li>用 download script + URL → external、commit URL 不 commit content</li>
</ul>
<p>這個 framework 跨任何技術 stack 都成立（不只 LLM）、但 LLM 應用尤其放大 derived / external 比例。</p>
<h2 id="llm-應用具體對應">LLM 應用具體對應</h2>
<h3 id="source進-git">Source（進 git）</h3>
<table>
  <thead>
      <tr>
          <th>產物</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>程式 source code</td>
          <td>wrapper script、framework 整合 code</td>
      </tr>
      <tr>
          <td>Prompt template</td>
          <td>system prompt、few-shot example、prompt structure</td>
      </tr>
      <tr>
          <td>Config schema</td>
          <td>哪些參數可調、合法範圍、default value</td>
      </tr>
      <tr>
          <td>Test fixture</td>
          <td>測試輸入 / 預期輸出 pair</td>
      </tr>
      <tr>
          <td>Markdown content（如本 blog）</td>
          <td>文章本身就是 source</td>
      </tr>
      <tr>
          <td><code>.gitignore</code> / lock file 規則</td>
          <td>描述哪些不進 git 也是 source</td>
      </tr>
      <tr>
          <td>Build script</td>
          <td><code>ingest.py</code>、<code>build.sh</code>、能從 source 重建 derived</td>
      </tr>
  </tbody>
</table>
<h3 id="derived不進-git但-build-path-進-git">Derived（不進 git、但 build path 進 git）</h3>
<table>
  <thead>
      <tr>
          <th>產物</th>
          <th>為什麼不 commit</th>
          <th>怎麼 share</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>index.pkl</code>（RAG embedding index）</td>
          <td>從 corpus + embedding model 重建、跟 model 版本綁、3.7 MB-GB 級</td>
          <td><code>ingest.py</code> script、跑一次就 reconstruct</td>
      </tr>
      <tr>
          <td>Embedding cache（per-document hash）</td>
          <td>跑時動態建、避免重 embed 同 chunk</td>
          <td>不 share、各自 rebuild</td>
      </tr>
      <tr>
          <td>Python <code>__pycache__/</code></td>
          <td>跑時自動產、Python 版本敏感</td>
          <td>不 share、各自 rebuild</td>
      </tr>
      <tr>
          <td>Compiled binary（如 <code>bin/mdtools</code>）</td>
          <td>從 Go source build、平台敏感</td>
          <td>source + build instructions、可選 release page 提供</td>
      </tr>
      <tr>
          <td>Generated docs（如 Hugo <code>public/</code>）</td>
          <td>從 markdown source build、deploy 時自動生</td>
          <td>source + deploy pipeline</td>
      </tr>
      <tr>
          <td>Log files</td>
          <td>runtime output、量大、有 PII 風險</td>
          <td>不 share、log retention 政策另立</td>
      </tr>
  </tbody>
</table>
<h3 id="external不進-git用-manifest--registry">External（不進 git、用 manifest / registry）</h3>
<table>
  <thead>
      <tr>
          <th>產物</th>
          <th>Manifest / registry</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>LLM model weights</td>
          <td>Hugging Face / Ollama registry tag</td>
          <td><code>nomic-embed-text:latest</code>、<code>sd_xl_base_1.0</code></td>
      </tr>
      <tr>
          <td>Python dependency</td>
          <td><code>requirements.txt</code> / <code>pyproject.toml</code></td>
          <td><code>requests==2.31.0</code></td>
      </tr>
      <tr>
          <td>Node modules</td>
          <td><code>package.json</code> + <code>package-lock.json</code></td>
          <td><code>react@18.2.0</code></td>
      </tr>
      <tr>
          <td>Dataset</td>
          <td><code>data.dvc</code> / S3 URL + checksum</td>
          <td>training data、eval set</td>
      </tr>
      <tr>
          <td>Docker image</td>
          <td><code>Dockerfile</code> + image tag</td>
          <td><code>python:3.11-slim</code></td>
      </tr>
  </tbody>
</table>
<p>External 跟 derived 的差別：external 來自 git 外的 source、derived 來自 git 內的 source。<strong>機制上都用同套路徑</strong>——manifest 進 git、實際 bytes 存 registry、避免大檔直接進 commit history。</p>
<h2 id="為什麼-derived--external-不該進-git">為什麼 derived / external 不該進 git</h2>
<p>每條限制有具體技術理由：</p>
<h3 id="size">Size</h3>
<p>Git 設計給 source code（小、純文字、頻繁 diff）。Derived / external 通常大、binary、不適合：</p>
<ul>
<li>Git 對 large binary 沒有有效 delta 演算法、每次小改 → 完整 copy 進 history</li>
<li>Repo size 線性漲、clone 變慢、CI cache 爆炸</li>
<li>GitHub 等服務有 file size 上限（GitHub 100 MB / file）</li>
</ul>
<p>實例：<code>scripts/rag-demo/index.pkl</code> 3.7 MB、每次 corpus 改 → 重 ingest → 整檔變。Commit 100 次 = git history 多 370 MB。Clone 痛。</p>
<h3 id="reproducibility反直覺">Reproducibility（反直覺）</h3>
<p>直覺：「commit derived 保證每個 clone 都拿到一樣的 output」——錯。</p>
<p>實際：</p>
<ul>
<li>Derived 跟 build env 綁（Python 3.13 build 的 pickle 在 3.14 不一定能 load）</li>
<li>Embedding index 跟 model version 綁（pull 不同 model 結果不同）</li>
<li>用舊 commit 的 derived 跑在新 env 反而比 rebuild 更脆弱</li>
</ul>
<p>正確 reproducibility 機制：commit <strong>build instruction + lockfile</strong>、別人 rebuild 時用同樣輸入產同樣 output。</p>
<h3 id="update-frequency-mismatch">Update frequency mismatch</h3>
<p>Source 改慢、derived 改快。<code>content/</code> 加一句話、<code>index.pkl</code> 整個重建。如果都進 git：</p>
<ul>
<li>90% 的 commit 是「rebuild artifact」、語意上不是真正的「source change」</li>
<li>git log 看不出真正 source 改動</li>
<li>diff review 被 derived noise 淹沒</li>
</ul>
<h3 id="cost--performance">Cost / Performance</h3>
<p>CI / CD pipeline 通常自動 rebuild derived。不 commit 反而：</p>
<ul>
<li>Source-only PR 較易 review（沒 generated diff）</li>
<li>CI build cache 重用、不需從 git 拉 derived</li>
<li>Deploy artifact registry 跟 git 分離、各自 scale</li>
</ul>
<h2 id="llm-應用-gitignore-設計模式">LLM 應用 <code>.gitignore</code> 設計模式</h2>
<p>LLM 應用典型 <code>.gitignore</code> 結構：</p>





<pre tabindex="0"><code class="language-gitignore" data-lang="gitignore"># === Source-side build output (derived) ===
# Compiled binaries
bin/
dist/
build/
*.pyc
__pycache__/

# Hugo / static site generators
public/
.hugo_build.lock
resources/

# RAG / vector indexes (regenerable)
scripts/rag-demo/index.pkl
*.pkl
*.index

# Embedding caches
.embedding_cache/
.vector_cache/

# === External-bound (don&#39;t commit, use manifest) ===
# Python deps (commit requirements.txt instead)
.venv/
venv/
env/

# Node deps
node_modules/

# Model weights / large files
*.safetensors
*.gguf
*.onnx
*.bin

# Datasets
data/raw/
data/processed/

# === Runtime / Local ===
# Logs
*.log
logs/

# OS / IDE
.DS_Store
.vscode/
.idea/

# Local secrets / API keys
.env
.env.local
*.key

# Temp / cache
*.tmp
.cache/</code></pre><h3 id="邊界-case-思考">邊界 case 思考</h3>
<p>幾個容易誤判的：</p>
<table>
  <thead>
      <tr>
          <th>產物</th>
          <th>該不該 commit</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>package-lock.json</code> / <code>poetry.lock</code></td>
          <td>commit</td>
          <td>是 manifest、保證 reproducibility</td>
      </tr>
      <tr>
          <td><code>node_modules/</code></td>
          <td>不 commit</td>
          <td>是 derived、可從 lockfile 重建</td>
      </tr>
      <tr>
          <td>小型 fixture data（&lt; 1 MB）</td>
          <td>commit（作 source）</td>
          <td>是 test 的一部分、不 reconstruct</td>
      </tr>
      <tr>
          <td>大型 eval dataset（&gt; 100 MB）</td>
          <td>用 dvc / S3 manifest</td>
          <td>量大、改用 dvc / S3 manifest 管理</td>
      </tr>
      <tr>
          <td>Pre-built model 用於 demo</td>
          <td>用 release artifact / Hugging Face</td>
          <td>量大、版本要可追蹤</td>
      </tr>
      <tr>
          <td>Prompt template (markdown / yaml)</td>
          <td>commit</td>
          <td>是 source、影響行為、要 diff</td>
      </tr>
      <tr>
          <td>從 LLM 生的 sample output</td>
          <td>不 commit（除非當 fixture）</td>
          <td>是 demo artifact、不 reconstruct 來源</td>
      </tr>
  </tbody>
</table>
<p>判讀 heuristic：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">這個檔案、半年後 production deploy 時要不要存在？
</span></span><span class="line"><span class="ln">2</span><span class="cl">├─ 要：source 或 manifest 進 git
</span></span><span class="line"><span class="ln">3</span><span class="cl">└─ 不要：runtime / 開發環境 only、用 .gitignore</span></span></code></pre></div><h3 id="三分類的退化情境">三分類的退化情境</h3>
<p>三分類是 default framework、實務上有幾類「該不該 commit 的判讀走兩條岔路」的情境、需要特別判讀：</p>
<ul>
<li><strong>Generated client SDK in monorepo</strong>：protobuf / OpenAPI spec 產出的 client code 屬於 derived（從 .proto / .yaml 生）、但 monorepo 場景常 commit 進去、目的是「跨語言版本對齊 + CI 不用每次重生」。判讀：若 .proto / spec 改動頻率低 + 跨語言一致性比 build 速度重要、commit；變動頻繁就回到 derived 路徑。</li>
<li><strong>Jupyter notebook 的 output cell</strong>：技術上是 derived（執行 notebook 產出）、但語意上常被視為 source 的一部分（教學、demo、結果展示）。判讀：教學 / 展示 / 帶 figures 的 notebook 通常 commit 含 output；機械化的 batch run / CI notebook 走 derived、用 nbstripout 清掉 output 再 commit。</li>
<li><strong>Git LFS / git-annex 介於 commit 跟 manifest 之間</strong>：把大檔案 commit 進 git 但實際 bytes 存 LFS server、worktree 看起來像直接 commit、metadata 卻是 manifest pointer。判讀：適合「需要在 git history 中追蹤大檔案版本、但不想讓 repo 體積爆炸」的場景（如 game asset、訓練資料集 snapshot）。介於 commit 跟 dvc / S3 manifest 之間的折衷選項。</li>
<li><strong>Lockfile vs build artifact 的灰色帶</strong>：<code>yarn-error.log</code> 算 log（不 commit）還是 derived 但對 debug 重要（commit）？實務上多數選 .gitignore、但若團隊在 CI 失敗時要 reproduce 環境、保留少量 build log 也合理。</li>
</ul>
<p>判讀原則：三分類給 default、灰色帶用「reproducibility + 變動頻率 + 團隊協作需求」三軸決定具體路徑。</p>
<h2 id="source--derived--external-的-share-機制">Source / Derived / External 的 share 機制</h2>
<p>不 commit 不代表不 share、只是用對的 channel。</p>
<h3 id="source-share--git">Source share = git</h3>
<p>直接 clone 即可。</p>
<h3 id="derived-share-三種模式">Derived share 三種模式</h3>
<ol>
<li><strong>Build script in repo</strong>：別人 clone 後跑 script 重建（本 blog 用這條：<code>ingest.py</code> 重建 index）
<ul>
<li>優點：無外部依賴、self-contained</li>
<li>缺點：每個 clone 都要重跑、累積 compute time</li>
</ul>
</li>
<li><strong>Release artifact</strong>：把 build output 上傳 GitHub Releases / S3、clone 後下載
<ul>
<li>優點：clone 快、不用各自 rebuild</li>
<li>缺點：要 maintain release pipeline、artifact 版本管理另立</li>
</ul>
</li>
<li><strong>Artifact registry</strong>：用 OCI registry、Docker registry、artifact storage（如 GitHub Packages / JFrog Artifactory）
<ul>
<li>優點：production-grade、跨 team / 跨 org share</li>
<li>缺點：複雜、配 auth、cost</li>
</ul>
</li>
</ol>
<p>選擇：小專案用 script、中型用 release、大型 / 多人 collaboration 用 registry。</p>
<h3 id="external-share--manifest">External share = manifest</h3>
<p>把「<strong>從哪下載 + checksum</strong>」commit 進 git、實際 content 不進。常見 manifest format：</p>
<table>
  <thead>
      <tr>
          <th>Manifest</th>
          <th>描述</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>requirements.txt</code> / <code>pyproject.toml</code></td>
          <td>Python deps + version</td>
      </tr>
      <tr>
          <td><code>package.json</code> + <code>package-lock.json</code></td>
          <td>Node deps + exact version + integrity hash</td>
      </tr>
      <tr>
          <td><code>Dockerfile</code></td>
          <td>OS + 環境 + 依賴 + entrypoint</td>
      </tr>
      <tr>
          <td><code>dvc.yaml</code> + <code>dvc.lock</code></td>
          <td>dataset + model version</td>
      </tr>
      <tr>
          <td>Ollama Modelfile（如果寫了）</td>
          <td>LLM model + system prompt 組合</td>
      </tr>
      <tr>
          <td><code>Cargo.lock</code> / <code>go.sum</code></td>
          <td>Rust / Go 的 dep checksum</td>
      </tr>
  </tbody>
</table>
<p>Manifest 自己是 source（人寫、進 git）、它指向的 external content 不進 git（用 download script 取回）。</p>
<h2 id="prompt-跟-config-的版本控制">Prompt 跟 config 的版本控制</h2>
<p>LLM 應用特有的問題：<strong>prompt template 是 source、但 prompt 改變影響行為跟 derived 改變不同</strong>。</p>
<table>
  <thead>
      <tr>
          <th>Prompt 操作</th>
          <th>git 行為</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改一個字</td>
          <td>一個 commit</td>
          <td>模型行為可能大變、要重跑 eval</td>
      </tr>
      <tr>
          <td>加 few-shot example</td>
          <td>一個 commit</td>
          <td>同上</td>
      </tr>
      <tr>
          <td>換不同模型（在 config）</td>
          <td>config commit</td>
          <td>用 prompt 沒變、行為變</td>
      </tr>
  </tbody>
</table>
<p>Prompt + model 是一對組合、行為相依、改一個都要重 test。建議在 commit message / PR description 描述「這個 prompt 改動的 expected behavior change」、用規格層級的 review 對待、勿視為 trivial 小改。</p>
<h3 id="prompt-跟-evaluation-一起管理">Prompt 跟 evaluation 一起管理</h3>
<p>進階做法：每個 prompt 配 evaluation set、commit 在同 PR：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">prompts/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── code_review.md           ← prompt template
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── code_review_eval.json    ← input + expected output pair
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── code_review_history.md   ← 改動記錄 + 對應 eval score</span></span></code></pre></div><p>每次改 prompt、跑 eval、比較 score、進 commit message。這比「改完 push 看看效果」可控很多、是 prompt engineering 的基本姿勢。</p>
<h2 id="production-deployment-的對接">Production deployment 的對接</h2>
<p>本地 hands-on 跟 production 對應：</p>
<table>
  <thead>
      <tr>
          <th>本地 hands-on</th>
          <th>Production</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>python ingest.py</code> build index</td>
          <td>Build pipeline 跑同樣 script、output 進 artifact storage</td>
      </tr>
      <tr>
          <td><code>ollama pull nomic-embed-text</code></td>
          <td>Container image 預載 model 或 mount volume</td>
      </tr>
      <tr>
          <td><code>.gitignore</code> 排除 index.pkl</td>
          <td>CI 自動 rebuild、deploy 時讀 artifact storage</td>
      </tr>
      <tr>
          <td>Source code 進 git</td>
          <td>Source 觸發 CI、build &amp; deploy</td>
      </tr>
  </tbody>
</table>
<p>成熟的 LLM 應用部署 pipeline：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Source change → git push
</span></span><span class="line"><span class="ln">2</span><span class="cl">              → CI triggered
</span></span><span class="line"><span class="ln">3</span><span class="cl">              → Build derived artifacts (index, container image)
</span></span><span class="line"><span class="ln">4</span><span class="cl">              → Run evaluation suite (prompt + model behavior tests)
</span></span><span class="line"><span class="ln">5</span><span class="cl">              → Push artifacts to registry
</span></span><span class="line"><span class="ln">6</span><span class="cl">              → Deploy with manifest pointing to specific artifact version
</span></span><span class="line"><span class="ln">7</span><span class="cl">              → Smoke test against production data
</span></span><span class="line"><span class="ln">8</span><span class="cl">              → Auto-rollback if metrics regress</span></span></code></pre></div><p>每一步都要 commit-able 的 manifest。在可審計 / 多人協作 / 有 SLA 承諾的場景、「手動 build 完 ssh 進 prod scp」這種 ad-hoc 流程會破壞 reproducibility、出問題時無法 revert 到具體 build；早期 prototype / 單人專案 / 一次性 demo 可接受 ad-hoc 流程、進入 production 前再改成 manifest-based。Manifest 是 reproducibility 跟 audit 的基礎。</p>
<h2 id="何時這篇會過時">何時這篇會過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>Source / derived / external 三分類 framework</li>
<li>「commit manifest、不 commit content」核心原則</li>
<li><code>.gitignore</code> 通用模式</li>
<li>Reproducibility 來自 build instruction、不來自 commit derived</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>具體 manifest format（半年一個新 lockfile 格式）</li>
<li>Artifact registry 主流（OCI / Conda / npm 等都會演化）</li>
<li>LLM model registry（Hugging Face / Ollama 都會演化）</li>
</ul>
<p>新 lock 格式 / registry 出來時、回到三分類問：它解的是哪類產物？我能用它 commit manifest 不 commit content 嗎？通常答案 yes。</p>
<h2 id="跟其他章節的關係">跟其他章節的關係</h2>
<ul>
<li><a href="https://github.com/tarrragon/blog/blob/main/scripts/README.md">scripts/README.md</a>：本章原理的實作 reference</li>
<li><a href="/blog/llm/01-local-llm-services/hands-on/quickstart/" data-link-title="Hands-on Quickstart：clone repo 後跑通所有 demo" data-link-desc="4 步驟跑通 RAG / MCP / permission demo 的 setup 跟驗證指令、整合 hands-on 系列所有章節的 prerequisite">Hands-on quickstart</a>：跑通 demo 步驟、為什麼要 rebuild <code>index.pkl</code></li>
<li><a href="/blog/llm/04-applications/production-resource-planning/" data-link-title="4.9 Production 部署的資源評估原理" data-link-desc="從本地單 user 到 production multi-tenant：concurrent users、cost model、observability、SLA、capacity planning 的設計取捨">4.9 Production resource planning</a>：production runtime 視角、本章是 deployment 視角</li>
<li><a href="/blog/llm/00-foundations/privacy-data-flow/" data-link-title="0.7 隱私 / 資安的資料流原理" data-link-desc="從「位置」到「資料流」的思考升級：信任邊界、合約模型、零信任原則套用到 LLM 工作流">0.7 隱私資料流原理</a>：什麼可以離開機器、本章是「什麼可以進 git」的 sibling</li>
<li><a href="/blog/llm/04-applications/vector-storage-engineering/" data-link-title="4.22 RAG storage 工程：從 pickle 到 vector database 的選型判讀" data-link-desc="RAG storage backend 選型：規模到哪個階段該從 in-memory 升級到 vector DB、dependency chain 如何收窄選項">4.22 RAG storage 工程</a>：本章把 embedding index 判為 derived（不進 git、<code>ingest.py</code> 重建）、該章接手 vector index 存進 backend 之後的生命週期管理</li>
</ul>
]]></content:encoded></item><item><title>程式碼版控與 FTP 部署紀律</title><link>https://tarrragon.github.io/blog/infra/takeover/legacy-code-versioning-deployment/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/takeover/legacy-code-versioning-deployment/</guid><description>&lt;p>無 SSH 環境的 PHP 專案通常沒有版本歷史——程式碼直接透過 FTP 覆蓋伺服器上的檔案，每次上傳就是一次不可回溯的覆寫。接手這類專案時，第一步是在本地建立 Git repo 作為程式碼的唯一事實來源，第二步是把 FTP 上傳從「隨手改隨手傳」轉成有紀錄、可回退的部署流程。本篇聚焦在程式碼端的版控與部署；資料庫的備份與變更紀律見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理&lt;/a>；帳號與存取的安全管理見&lt;a href="https://tarrragon.github.io/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點&lt;/a>。&lt;/p>
&lt;h2 id="從-ftp-拉下來建立-git-repo">從 FTP 拉下來建立 Git repo&lt;/h2>
&lt;p>用 FTP client 把整個站台完整下載到本地目錄，這份下載就是 production 的快照。下載完成後在該目錄初始化 Git：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /path/to/downloaded-site
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git init&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>在第一次 commit 之前先處理 &lt;code>.gitignore&lt;/code>。PHP 專案需要排除的檔案分三類：套件依賴（由 Composer 或 npm 管理、可重建）、執行期產物（快取、session、上傳檔案）、以及含有機密值的設定檔。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl"># 套件依賴
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">vendor/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">node_modules/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"># 執行期產物
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">cache/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">tmp/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">sessions/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">*.log
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"># 使用者上傳內容（通常很大、且屬於資料不屬於程式碼）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">uploads/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">media/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">wp-content/uploads/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"># 機密設定（下一節處理）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">.env
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">config.local.php
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">wp-config.php&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>使用者上傳的內容（&lt;code>uploads/&lt;/code>、&lt;code>media/&lt;/code>）不進 Git 的理由是它屬於資料層：檔案數量可能成千上萬、總容量可能數 GB，Git 不適合管理這類大量二進位檔案。這些檔案的備份策略跟程式碼不同——用 FTP mirror 或 rclone 定期同步到本地即可。&lt;/p>
&lt;p>設好 &lt;code>.gitignore&lt;/code> 後做第一次 commit：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git add -A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;production snapshot &lt;/span>&lt;span class="k">$(&lt;/span>date +%Y-%m-%d&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 commit 就是「接手時 production 長什麼樣」的基準線。後續所有改動都從這裡開始有版本歷史。&lt;/p>
&lt;h2 id="config-分離讓-git-repo-不含機密值">Config 分離：讓 Git repo 不含機密值&lt;/h2>
&lt;p>無 SSH 環境的 PHP 專案常把資料庫密碼、API key、SMTP 憑證直接寫在 &lt;code>config.php&lt;/code> 或 &lt;code>wp-config.php&lt;/code> 裡。這些檔案如果進了 Git，機密值就跟著 repo 走——推到 GitHub 就等於公開。&lt;/p>
&lt;p>分離的模式是把設定拆成兩份：一份進 Git（結構與預設值）、一份不進 Git（實際機密值）。&lt;/p>
&lt;h3 id="模式一env-檔案">模式一：.env 檔案&lt;/h3>
&lt;p>使用 &lt;code>vlucas/phpdotenv&lt;/code> 套件或手動解析，讓程式碼從 &lt;code>.env&lt;/code> 檔案讀取環境變數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// config.php — 進 Git
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nv">$dotenv&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">Dotenv\Dotenv&lt;/span>&lt;span class="o">::&lt;/span>&lt;span class="na">createImmutable&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="no">__DIR__&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nv">$dotenv&lt;/span>&lt;span class="o">-&amp;gt;&lt;/span>&lt;span class="na">load&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_host&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_HOST&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_NAME&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_USER&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_pass&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nv">$_ENV&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;DB_PASS&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"># .env — 不進 Git（.gitignore 已排除）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">DB_HOST=localhost
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB_NAME=mysite_prod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DB_USER=mysite_user
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">DB_PASS=actual-password-here&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同時在 repo 裡放一份 &lt;code>.env.example&lt;/code>（進 Git），列出所有需要的環境變數但不填實際值：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"># .env.example — 進 Git，作為範本
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">DB_HOST=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB_NAME=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DB_USER=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">DB_PASS=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">SMTP_HOST=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">SMTP_USER=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">SMTP_PASS=&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="模式二configlocalphp">模式二：config.local.php&lt;/h3>
&lt;p>如果專案不使用 Composer、引入 phpdotenv 成本太高，用 PHP include 分離：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// config.php — 進 Git
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">file_exists&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="no">__DIR__&lt;/span> &lt;span class="o">.&lt;/span> &lt;span class="s1">&amp;#39;/config.local.php&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">require&lt;/span> &lt;span class="no">__DIR__&lt;/span> &lt;span class="o">.&lt;/span> &lt;span class="s1">&amp;#39;/config.local.php&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">die&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;config.local.php not found. Copy config.local.example.php and fill in values.&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// config.local.php — 不進 Git
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nv">$db_host&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;localhost&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;mysite_prod&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;mysite_user&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nv">$db_pass&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;actual-password-here&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="wordpress-的處理">WordPress 的處理&lt;/h3>
&lt;p>WordPress 的 &lt;code>wp-config.php&lt;/code> 同時包含機密值和非機密設定。把整份排除再 include 一份 local 版是最簡單的做法，但也可以只把機密值抽到 &lt;code>.env&lt;/code>、&lt;code>wp-config.php&lt;/code> 本身保留在 Git 裡：&lt;/p></description><content:encoded><![CDATA[<p>無 SSH 環境的 PHP 專案通常沒有版本歷史——程式碼直接透過 FTP 覆蓋伺服器上的檔案，每次上傳就是一次不可回溯的覆寫。接手這類專案時，第一步是在本地建立 Git repo 作為程式碼的唯一事實來源，第二步是把 FTP 上傳從「隨手改隨手傳」轉成有紀錄、可回退的部署流程。本篇聚焦在程式碼端的版控與部署；資料庫的備份與變更紀律見<a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>；帳號與存取的安全管理見<a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>。</p>
<h2 id="從-ftp-拉下來建立-git-repo">從 FTP 拉下來建立 Git repo</h2>
<p>用 FTP client 把整個站台完整下載到本地目錄，這份下載就是 production 的快照。下載完成後在該目錄初始化 Git：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /path/to/downloaded-site
</span></span><span class="line"><span class="ln">2</span><span class="cl">git init</span></span></code></pre></div><p>在第一次 commit 之前先處理 <code>.gitignore</code>。PHP 專案需要排除的檔案分三類：套件依賴（由 Composer 或 npm 管理、可重建）、執行期產物（快取、session、上傳檔案）、以及含有機密值的設定檔。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl"># 套件依賴
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">vendor/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">node_modules/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"># 執行期產物
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">cache/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">tmp/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">sessions/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">*.log
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"># 使用者上傳內容（通常很大、且屬於資料不屬於程式碼）
</span></span><span class="line"><span class="ln">12</span><span class="cl">uploads/
</span></span><span class="line"><span class="ln">13</span><span class="cl">media/
</span></span><span class="line"><span class="ln">14</span><span class="cl">wp-content/uploads/
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"># 機密設定（下一節處理）
</span></span><span class="line"><span class="ln">17</span><span class="cl">.env
</span></span><span class="line"><span class="ln">18</span><span class="cl">config.local.php
</span></span><span class="line"><span class="ln">19</span><span class="cl">wp-config.php</span></span></code></pre></div><p>使用者上傳的內容（<code>uploads/</code>、<code>media/</code>）不進 Git 的理由是它屬於資料層：檔案數量可能成千上萬、總容量可能數 GB，Git 不適合管理這類大量二進位檔案。這些檔案的備份策略跟程式碼不同——用 FTP mirror 或 rclone 定期同步到本地即可。</p>
<p>設好 <code>.gitignore</code> 後做第一次 commit：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git add -A
</span></span><span class="line"><span class="ln">2</span><span class="cl">git commit -m <span class="s2">&#34;production snapshot </span><span class="k">$(</span>date +%Y-%m-%d<span class="k">)</span><span class="s2">&#34;</span></span></span></code></pre></div><p>這個 commit 就是「接手時 production 長什麼樣」的基準線。後續所有改動都從這裡開始有版本歷史。</p>
<h2 id="config-分離讓-git-repo-不含機密值">Config 分離：讓 Git repo 不含機密值</h2>
<p>無 SSH 環境的 PHP 專案常把資料庫密碼、API key、SMTP 憑證直接寫在 <code>config.php</code> 或 <code>wp-config.php</code> 裡。這些檔案如果進了 Git，機密值就跟著 repo 走——推到 GitHub 就等於公開。</p>
<p>分離的模式是把設定拆成兩份：一份進 Git（結構與預設值）、一份不進 Git（實際機密值）。</p>
<h3 id="模式一env-檔案">模式一：.env 檔案</h3>
<p>使用 <code>vlucas/phpdotenv</code> 套件或手動解析，讓程式碼從 <code>.env</code> 檔案讀取環境變數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// config.php — 進 Git
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$dotenv</span> <span class="o">=</span> <span class="nx">Dotenv\Dotenv</span><span class="o">::</span><span class="na">createImmutable</span><span class="p">(</span><span class="no">__DIR__</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$dotenv</span><span class="o">-&gt;</span><span class="na">load</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nv">$db_host</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_HOST&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nv">$db_name</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_NAME&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nv">$db_user</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_USER&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nv">$db_pass</span> <span class="o">=</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_PASS&#39;</span><span class="p">];</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># .env — 不進 Git（.gitignore 已排除）
</span></span><span class="line"><span class="ln">2</span><span class="cl">DB_HOST=localhost
</span></span><span class="line"><span class="ln">3</span><span class="cl">DB_NAME=mysite_prod
</span></span><span class="line"><span class="ln">4</span><span class="cl">DB_USER=mysite_user
</span></span><span class="line"><span class="ln">5</span><span class="cl">DB_PASS=actual-password-here</span></span></code></pre></div><p>同時在 repo 裡放一份 <code>.env.example</code>（進 Git），列出所有需要的環境變數但不填實際值：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># .env.example — 進 Git，作為範本
</span></span><span class="line"><span class="ln">2</span><span class="cl">DB_HOST=
</span></span><span class="line"><span class="ln">3</span><span class="cl">DB_NAME=
</span></span><span class="line"><span class="ln">4</span><span class="cl">DB_USER=
</span></span><span class="line"><span class="ln">5</span><span class="cl">DB_PASS=
</span></span><span class="line"><span class="ln">6</span><span class="cl">SMTP_HOST=
</span></span><span class="line"><span class="ln">7</span><span class="cl">SMTP_USER=
</span></span><span class="line"><span class="ln">8</span><span class="cl">SMTP_PASS=</span></span></code></pre></div><h3 id="模式二configlocalphp">模式二：config.local.php</h3>
<p>如果專案不使用 Composer、引入 phpdotenv 成本太高，用 PHP include 分離：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// config.php — 進 Git
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="nx">file_exists</span><span class="p">(</span><span class="no">__DIR__</span> <span class="o">.</span> <span class="s1">&#39;/config.local.php&#39;</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">require</span> <span class="no">__DIR__</span> <span class="o">.</span> <span class="s1">&#39;/config.local.php&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">die</span><span class="p">(</span><span class="s1">&#39;config.local.php not found. Copy config.local.example.php and fill in values.&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// config.local.php — 不進 Git
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$db_host</span> <span class="o">=</span> <span class="s1">&#39;localhost&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$db_name</span> <span class="o">=</span> <span class="s1">&#39;mysite_prod&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nv">$db_user</span> <span class="o">=</span> <span class="s1">&#39;mysite_user&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nv">$db_pass</span> <span class="o">=</span> <span class="s1">&#39;actual-password-here&#39;</span><span class="p">;</span></span></span></code></pre></div><h3 id="wordpress-的處理">WordPress 的處理</h3>
<p>WordPress 的 <code>wp-config.php</code> 同時包含機密值和非機密設定。把整份排除再 include 一份 local 版是最簡單的做法，但也可以只把機密值抽到 <code>.env</code>、<code>wp-config.php</code> 本身保留在 Git 裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// wp-config.php — 進 Git（機密值從 .env 讀）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$dotenv</span> <span class="o">=</span> <span class="nx">Dotenv\Dotenv</span><span class="o">::</span><span class="na">createImmutable</span><span class="p">(</span><span class="no">__DIR__</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$dotenv</span><span class="o">-&gt;</span><span class="na">load</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_NAME&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_NAME&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_USER&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_USER&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_PASSWORD&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_PASSWORD&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nx">define</span><span class="p">(</span><span class="s1">&#39;DB_HOST&#39;</span><span class="p">,</span> <span class="nv">$_ENV</span><span class="p">[</span><span class="s1">&#39;DB_HOST&#39;</span><span class="p">]</span> <span class="o">??</span> <span class="s1">&#39;localhost&#39;</span><span class="p">);</span></span></span></code></pre></div><p>分離完成後，用 <code>grep</code> 確認 repo 裡沒有殘留的明文密碼：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git grep -in <span class="s2">&#34;password\|passwd\|secret\|api_key\|smtp&#34;</span> -- <span class="s1">&#39;*.php&#39;</span> <span class="s1">&#39;:!*.example*&#39;</span></span></span></code></pre></div><p>任何命中都要評估：是真的機密值（要移到 .env）還是變數名稱（可以保留）。</p>
<h2 id="ftp-部署的風險控制">FTP 部署的風險控制</h2>
<p>FTP 上傳是逐檔覆寫，沒有交易性——上傳到一半斷線、或上傳了有語法錯誤的 PHP 檔案，站台會立刻出問題。風險控制的核心是「每次上傳前知道在改什麼、上傳後知道改了什麼」。</p>
<h3 id="上傳前的比對">上傳前的比對</h3>
<p>FileZilla 的目錄比較功能（「檢視 → 目錄比較 → 啟用」）可以在上傳前看到本地與遠端的差異：哪些檔案是本地較新、哪些是遠端較新、哪些只存在於一邊。上傳前先跑比較、確認差異清單符合預期——如果出現預期外的「遠端較新」檔案，代表有人在伺服器上直接改了東西，要先下載回來合併再上傳。</p>
<h3 id="只上傳改過的檔案">只上傳改過的檔案</h3>
<p>一次上傳整個站台目錄既慢又危險。只上傳 Git diff 顯示的改動檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 列出相對於上次部署 tag 改了哪些檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git diff --name-only deploy-2026-06-25 HEAD</span></span></code></pre></div><p>把這份清單對照 FileZilla 的比較結果，逐一上傳。量大時用 lftp 的 mirror 指令加 <code>--only-newer</code> flag 只傳新檔。</p>
<h3 id="關鍵檔案的額外保護">關鍵檔案的額外保護</h3>
<p><code>index.php</code>、<code>.htaccess</code>、設定檔這類檔案壞掉會讓整個站台無法存取。上傳這些檔案之前，先從伺服器下載一份當前版本存到本地的 <code>_backup/</code> 目錄（gitignored）。如果上傳後站台出問題，可以立刻把備份版本傳回去。</p>
<h2 id="部署前後的驗證">部署前後的驗證</h2>
<h3 id="部署前檢查">部署前檢查</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>確認方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本地測試通過</td>
          <td>在本地環境跑過改動的頁面 / 功能</td>
      </tr>
      <tr>
          <td>Git 已 commit</td>
          <td><code>git status</code> 顯示 clean</td>
      </tr>
      <tr>
          <td>要上傳的檔案清單已確認</td>
          <td><code>git diff --name-only</code> 輸出符合預期</td>
      </tr>
      <tr>
          <td>關鍵檔案已備份</td>
          <td><code>_backup/</code> 有當前版本</td>
      </tr>
  </tbody>
</table>
<h3 id="部署後驗證">部署後驗證</h3>
<p>上傳完成後立刻驗證：</p>
<ol>
<li>首頁能正常載入（HTTP 200、頁面內容正確）</li>
<li>本次改動涉及的功能可正常操作</li>
<li>如果是電商站：結帳流程、金流 callback 測試</li>
<li>檢查 PHP error log（cPanel → 錯誤日誌、或 FTP 下載 <code>error_log</code> 檔案）</li>
</ol>
<p>如果驗證失敗，回退方式是從 Git 歷史取出上一個版本的受影響檔案重新上傳：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 取出上一個部署 tag 的特定檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git show deploy-2026-06-25:path/to/file.php &gt; _rollback/file.php
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 用 FTP 上傳 _rollback/file.php 覆蓋 prod</span></span></span></code></pre></div><h2 id="ci-化-ftp-部署">CI 化 FTP 部署</h2>
<p>手動 FTP 部署的問題是它依賴特定人的 FTP client 和操作紀律。用 GitHub Actions 把 FTP 上傳自動化，可以讓部署變成「push 到 main → CI 跑測試 → CI 上傳到伺服器」的流程，不依賴任何人的本地環境。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy via FTP</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to FTP</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">SamKirkland/FTP-Deploy-Action@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">          </span><span class="nt">server</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_HOST }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">          </span><span class="nt">username</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_USER }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">          </span><span class="nt">password</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FTP_PASS }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">          </span><span class="nt">server-dir</span><span class="p">:</span><span class="w"> </span><span class="l">/public_html/</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">          </span><span class="nt">exclude</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="sd">            **/.git*
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="sd">            **/.git*/**
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="sd">            **/node_modules/**
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="sd">            **/.env
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="sd">            **/config.local.php</span></span></span></code></pre></div><p>FTP 憑證存在 GitHub repo 的 Secrets 裡（Settings → Secrets and variables → Actions），不寫在 workflow 檔案裡。</p>
<h3 id="ci-化後的改變">CI 化後的改變</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>手動 FTP</th>
          <th>CI 化 FTP</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>部署紀錄</td>
          <td>FTP client 的 log（通常不保留）</td>
          <td>GitHub Actions 的 run history（永久保留）</td>
      </tr>
      <tr>
          <td>部署觸發</td>
          <td>某人手動操作</td>
          <td>push 到 main 自動觸發</td>
      </tr>
      <tr>
          <td>上傳前測試</td>
          <td>依賴個人紀律</td>
          <td>CI 可加 lint / test step</td>
      </tr>
      <tr>
          <td>多人協作</td>
          <td>需要共用 FTP 帳密</td>
          <td>帳密在 GitHub Secrets、workflow 共用</td>
      </tr>
  </tbody>
</table>
<h3 id="限制">限制</h3>
<p>FTP 部署沒有原子性（atomic deployment）——檔案逐一上傳的過程中，伺服器上同時存在新舊版本的檔案混合狀態。如果上傳的檔案之間有依賴關係（新的 A.php 引用新的 B.php，但 B.php 還沒上傳完），短暫的錯誤窗口無法避免。流量高的站台如果需要零停機部署，需要升級到 SSH + symlink 切換的部署方式，那屬於 VPS 遷移之後的能力。</p>
<h2 id="git-tagging-部署紀錄">Git tagging 部署紀錄</h2>
<p>每次部署前在 Git 打一個 tag，讓「這次部署的是哪個版本」有明確的錨點：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git tag deploy-<span class="k">$(</span>date +%Y-%m-%d-%H%M<span class="k">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git push origin --tags</span></span></code></pre></div><p>tag 的命名用日期時間戳而非版號，因為這類專案通常沒有語意化版號的概念。tag 的作用是：</p>
<ul>
<li>回退時知道要退到哪個版本（<code>git diff deploy-previous deploy-current</code> 看這次改了什麼）</li>
<li>多次部署之間的差異可追蹤</li>
<li>CI 化後可以用 tag 觸發部署而非每次 push 都部署</li>
</ul>
<p>資料庫變更的回退跟程式碼獨立處理——程式碼可以靠 Git 回退，資料庫要靠 SQL dump 回退，兩者的回退點要對齊但機制不同。資料庫的備份策略見<a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">無 SSH 的 FTP / 面板管理環境接管</a>：本篇的母文章，涵蓋接手的完整流程</li>
<li>→ <a href="/blog/infra/takeover/legacy-database-backup-migration/" data-link-title="無 SSH 環境的資料庫備份與變更管理" data-link-desc="在只有 phpMyAdmin 或有限遠端連線的無 SSH 環境裡，怎麼建立可靠的資料庫備份策略、schema 變更紀律與還原演練流程">資料庫備份與變更管理</a>：資料庫端的備份、migration 紀律與回退策略</li>
<li>→ <a href="/blog/infra/takeover/legacy-php-security-audit/" data-link-title="Legacy PHP 的安全盤點" data-link-desc="接手 legacy PHP 專案後的系統性安全審查：credential 掃描、PHP 版本風險、常見漏洞模式的 grep 偵測、.htaccess 防線、檔案權限、外部依賴與掃描工具">Legacy PHP 的安全盤點</a>：credential 分離之後的存取控制與安全掃描</li>
<li>→ <a href="/blog/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</a>：部署後用外部監控驗證服務正常</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：從 FTP CI 化進一步演進到完整的 PR review 流程</li>
</ul>
]]></content:encoded></item><item><title>「先還原」「先重來」類退出指令的處理</title><link>https://tarrragon.github.io/blog/report/revert-instruction-handling/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/revert-instruction-handling/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>退出指令（「先還原」「先重來」「先放著」）的執行前要先確認兩件事：還原到哪個狀態、要不要先存 checkpoint。&lt;/strong> 直接刪掉當前進度、未來想比較沒得比、想恢復也找不到。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼退出指令需要-protocol">為什麼退出指令需要 protocol&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>退出指令的字面意思是「拿掉現在做的東西」、但背後通常有更複雜的意圖：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>字面&lt;/th>
 &lt;th>可能的真實意圖&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>「先還原」&lt;/td>
 &lt;td>暫時不用、之後可能重做&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「先還原」&lt;/td>
 &lt;td>完全放棄、不會再做&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「先還原」&lt;/td>
 &lt;td>換個方向重做&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「先重來」&lt;/td>
 &lt;td>從上一個 commit 重新試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「先重來」&lt;/td>
 &lt;td>從更早的點重新試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>執行者不確認意圖、直接刪 = 把使用者的選項收窄成「沒有了」。事先確認 = 為使用者保留可能性。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>使用者說：「如果要做到這麼大的複寫原設計、我們先還原回去、先不要做這個變更」。&lt;/p>
&lt;p>我立刻刪掉那段 CSS、繼續往下做其他事。沒問「還原到哪」、沒先 commit checkpoint。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>後來想比較「複雜覆寫版本」與「接受原設計版本」的差異 — 沒得比。「複雜覆寫版本」已經不存在於 git 歷史。&lt;/p>
&lt;p>正確流程應該是：&lt;/p>
&lt;ol>
&lt;li>收到「還原」指令時、先回應「我會先 commit 當前進度（即使未採用）作為 checkpoint，再還原到 X 狀態。OK 嗎？」&lt;/li>
&lt;li>確認後、commit 當前狀態（不 push）、commit message 標明「探索版本、未採用」&lt;/li>
&lt;li>還原到 X 狀態&lt;/li>
&lt;li>繼續做後續事&lt;/li>
&lt;/ol>
&lt;p>未來想比較或恢復、checkpoint 都還在。&lt;/p>
&lt;h3 id="執行退出指令的-protocol">執行：退出指令的 protocol&lt;/h3>
&lt;p>收到退出指令時、依序處理：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>暫停執行、不要立刻刪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>確認「還原到哪個狀態」（上個 commit / 特定 commit / 完全乾淨）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>評估「當前進度有保留價值嗎」 — 探索成果、未採用方案、可能未來重用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>有保留價值 → 先 commit checkpoint、message 標明「探索、未採用」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>執行還原（reset / checkout / revert）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>6&lt;/td>
 &lt;td>確認還原後狀態符合使用者意圖&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="checkpoint-的命名與-commit-message">Checkpoint 的命名與 commit message&lt;/h2>
&lt;p>當前進度作為 checkpoint commit、message 應該包含：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">chore(explore): N+1 attempts to remove disclosure marker
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">探索方向：用 ::-webkit-details-marker / ::marker / display: block 三層
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">覆寫移除 disclosure 三角圖示。
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">寫了 5 條 CSS 跨 3 種瀏覽器、覆寫成本過高。
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">最終決定：接受原設計、不採用此方向。
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">此 commit 保留探索成果、未來若評估值得做時可參考。&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>關鍵元素：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>chore(explore):&lt;/code>&lt;/strong> prefix — 標明非正式採用&lt;/li>
&lt;li>&lt;strong>探索方向&lt;/strong> — 說明做了什麼&lt;/li>
&lt;li>&lt;strong>最終決定&lt;/strong> — 為什麼不採用&lt;/li>
&lt;li>&lt;strong>保留理由&lt;/strong> — 未來何時可能重用&lt;/li>
&lt;/ul>
&lt;p>未來人看到這個 commit、知道「這是探索、不是當前實作」、不會誤用。&lt;/p>
&lt;hr>
&lt;h2 id="內在屬性比較四種退出處理">內在屬性比較：四種退出處理&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>處理&lt;/th>
 &lt;th>可逆性&lt;/th>
 &lt;th>比較成本&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>直接刪、不留紀錄&lt;/td>
 &lt;td>最低 — 永久失去&lt;/td>
 &lt;td>不可比較&lt;/td>
 &lt;td>不適用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>git reset --hard&lt;/code>（未 commit 直接丟）&lt;/td>
 &lt;td>低 — git reflog 短期還在&lt;/td>
 &lt;td>困難&lt;/td>
 &lt;td>確定不要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>git stash&lt;/code>（暫存到 stash）&lt;/td>
 &lt;td>中 — stash 可隨時恢復&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>短期暫停&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>git commit&lt;/code> checkpoint 後 reset / revert&lt;/td>
 &lt;td>高 — commit 永久存在&lt;/td>
 &lt;td>容易&lt;/td>
 &lt;td>探索性嘗試、未採用方案&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優先選 checkpoint 方式 — 為未來保留比較與恢復的可能性。&lt;/p>
&lt;hr>
&lt;h2 id="退出意圖的辨識">退出意圖的辨識&lt;/h2>
&lt;h3 id="從語氣判斷">從語氣判斷&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>語氣&lt;/th>
 &lt;th>對應意圖&lt;/th>
 &lt;th>推薦處理&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>「先還原」&lt;/td>
 &lt;td>暫時、可能重做&lt;/td>
 &lt;td>Checkpoint + 還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「先還原、不要做了」&lt;/td>
 &lt;td>確定放棄&lt;/td>
 &lt;td>Checkpoint（小機率重看） + 還原&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「重來」&lt;/td>
 &lt;td>換個方向&lt;/td>
 &lt;td>Checkpoint + 還原到起點&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「不要這樣寫」&lt;/td>
 &lt;td>局部修正、不是全還原&lt;/td>
 &lt;td>局部改、不要全還原&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不確定就問。「不要這樣寫」這類指令容易被理解成「全還原」、實際只是「這段重寫」。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>退出指令（「先還原」「先重來」「先放著」）的執行前要先確認兩件事：還原到哪個狀態、要不要先存 checkpoint。</strong> 直接刪掉當前進度、未來想比較沒得比、想恢復也找不到。</p>
<hr>
<h2 id="為什麼退出指令需要-protocol">為什麼退出指令需要 protocol</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>退出指令的字面意思是「拿掉現在做的東西」、但背後通常有更複雜的意圖：</p>
<table>
  <thead>
      <tr>
          <th>字面</th>
          <th>可能的真實意圖</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「先還原」</td>
          <td>暫時不用、之後可能重做</td>
      </tr>
      <tr>
          <td>「先還原」</td>
          <td>完全放棄、不會再做</td>
      </tr>
      <tr>
          <td>「先還原」</td>
          <td>換個方向重做</td>
      </tr>
      <tr>
          <td>「先重來」</td>
          <td>從上一個 commit 重新試</td>
      </tr>
      <tr>
          <td>「先重來」</td>
          <td>從更早的點重新試</td>
      </tr>
  </tbody>
</table>
<p>執行者不確認意圖、直接刪 = 把使用者的選項收窄成「沒有了」。事先確認 = 為使用者保留可能性。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>使用者說：「如果要做到這麼大的複寫原設計、我們先還原回去、先不要做這個變更」。</p>
<p>我立刻刪掉那段 CSS、繼續往下做其他事。沒問「還原到哪」、沒先 commit checkpoint。</p>
<h3 id="判讀">判讀</h3>
<p>後來想比較「複雜覆寫版本」與「接受原設計版本」的差異 — 沒得比。「複雜覆寫版本」已經不存在於 git 歷史。</p>
<p>正確流程應該是：</p>
<ol>
<li>收到「還原」指令時、先回應「我會先 commit 當前進度（即使未採用）作為 checkpoint，再還原到 X 狀態。OK 嗎？」</li>
<li>確認後、commit 當前狀態（不 push）、commit message 標明「探索版本、未採用」</li>
<li>還原到 X 狀態</li>
<li>繼續做後續事</li>
</ol>
<p>未來想比較或恢復、checkpoint 都還在。</p>
<h3 id="執行退出指令的-protocol">執行：退出指令的 protocol</h3>
<p>收到退出指令時、依序處理：</p>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>暫停執行、不要立刻刪</td>
      </tr>
      <tr>
          <td>2</td>
          <td>確認「還原到哪個狀態」（上個 commit / 特定 commit / 完全乾淨）</td>
      </tr>
      <tr>
          <td>3</td>
          <td>評估「當前進度有保留價值嗎」 — 探索成果、未採用方案、可能未來重用</td>
      </tr>
      <tr>
          <td>4</td>
          <td>有保留價值 → 先 commit checkpoint、message 標明「探索、未採用」</td>
      </tr>
      <tr>
          <td>5</td>
          <td>執行還原（reset / checkout / revert）</td>
      </tr>
      <tr>
          <td>6</td>
          <td>確認還原後狀態符合使用者意圖</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="checkpoint-的命名與-commit-message">Checkpoint 的命名與 commit message</h2>
<p>當前進度作為 checkpoint commit、message 應該包含：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">chore(explore): N+1 attempts to remove disclosure marker
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">探索方向：用 ::-webkit-details-marker / ::marker / display: block 三層
</span></span><span class="line"><span class="ln">4</span><span class="cl">覆寫移除 disclosure 三角圖示。
</span></span><span class="line"><span class="ln">5</span><span class="cl">寫了 5 條 CSS 跨 3 種瀏覽器、覆寫成本過高。
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl">最終決定：接受原設計、不採用此方向。
</span></span><span class="line"><span class="ln">8</span><span class="cl">此 commit 保留探索成果、未來若評估值得做時可參考。</span></span></code></pre></div><p>關鍵元素：</p>
<ul>
<li><strong><code>chore(explore):</code></strong> prefix — 標明非正式採用</li>
<li><strong>探索方向</strong> — 說明做了什麼</li>
<li><strong>最終決定</strong> — 為什麼不採用</li>
<li><strong>保留理由</strong> — 未來何時可能重用</li>
</ul>
<p>未來人看到這個 commit、知道「這是探索、不是當前實作」、不會誤用。</p>
<hr>
<h2 id="內在屬性比較四種退出處理">內在屬性比較：四種退出處理</h2>
<table>
  <thead>
      <tr>
          <th>處理</th>
          <th>可逆性</th>
          <th>比較成本</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>直接刪、不留紀錄</td>
          <td>最低 — 永久失去</td>
          <td>不可比較</td>
          <td>不適用</td>
      </tr>
      <tr>
          <td><code>git reset --hard</code>（未 commit 直接丟）</td>
          <td>低 — git reflog 短期還在</td>
          <td>困難</td>
          <td>確定不要</td>
      </tr>
      <tr>
          <td><code>git stash</code>（暫存到 stash）</td>
          <td>中 — stash 可隨時恢復</td>
          <td>中</td>
          <td>短期暫停</td>
      </tr>
      <tr>
          <td><code>git commit</code> checkpoint 後 reset / revert</td>
          <td>高 — commit 永久存在</td>
          <td>容易</td>
          <td>探索性嘗試、未採用方案</td>
      </tr>
  </tbody>
</table>
<p>優先選 checkpoint 方式 — 為未來保留比較與恢復的可能性。</p>
<hr>
<h2 id="退出意圖的辨識">退出意圖的辨識</h2>
<h3 id="從語氣判斷">從語氣判斷</h3>
<table>
  <thead>
      <tr>
          <th>語氣</th>
          <th>對應意圖</th>
          <th>推薦處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「先還原」</td>
          <td>暫時、可能重做</td>
          <td>Checkpoint + 還原</td>
      </tr>
      <tr>
          <td>「先還原、不要做了」</td>
          <td>確定放棄</td>
          <td>Checkpoint（小機率重看） + 還原</td>
      </tr>
      <tr>
          <td>「重來」</td>
          <td>換個方向</td>
          <td>Checkpoint + 還原到起點</td>
      </tr>
      <tr>
          <td>「不要這樣寫」</td>
          <td>局部修正、不是全還原</td>
          <td>局部改、不要全還原</td>
      </tr>
  </tbody>
</table>
<p>不確定就問。「不要這樣寫」這類指令容易被理解成「全還原」、實際只是「這段重寫」。</p>
<h3 id="確認意圖的問法">確認意圖的問法</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">你想要：
</span></span><span class="line"><span class="ln">2</span><span class="cl">  □ 完全還原、刪掉這次的 CSS
</span></span><span class="line"><span class="ln">3</span><span class="cl">  □ 還原但保留 commit checkpoint、未來可能參考
</span></span><span class="line"><span class="ln">4</span><span class="cl">  □ 局部修正（哪一段保留、哪一段重寫）
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">或是其他？</span></span></code></pre></div><hr>
<h2 id="設計取捨退出指令的處理策略">設計取捨：退出指令的處理策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（Checkpoint + 確認還原範圍）當預設、其他做法在特定情境合理。</p>
<h3 id="acommit-checkpoint--確認還原範圍這個專案的預設">A：Commit checkpoint + 確認還原範圍（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：commit 當前進度（標明「探索、未採用」）+ 問「還原到哪個 commit」+ 執行還原</li>
<li><strong>選 A 的理由</strong>：未來想比較或恢復都還在、checkpoint 成本只是多一個 commit</li>
<li><strong>適合</strong>：探索性嘗試、未採用方案的退出</li>
<li><strong>代價</strong>：多一個 commit 在歷史中（可後續 squash 或保留）</li>
</ul>
<h3 id="bgit-stash-暫存">B：<code>git stash</code> 暫存</h3>
<ul>
<li><strong>機制</strong>：把未 commit 的變更存到 stash、不污染 commit 歷史</li>
<li><strong>跟 A 的取捨</strong>：B 不留 commit、A 留 commit；B 適合「短期暫停、之後恢復」、A 適合「長期探索紀錄」</li>
<li><strong>B 比 A 好的情境</strong>：暫時切到別的工作、之後一定會回來繼續</li>
</ul>
<h3 id="cgit-reset---hard直接丟">C：<code>git reset --hard</code>（直接丟）</h3>
<ul>
<li><strong>機制</strong>：reset 到指定 commit、未 commit 的變更全失去</li>
<li><strong>跟 A 的取捨</strong>：C 完全清乾淨、A 保留紀錄；C 在 git reflog 內短期還能恢復、但之後永久消失</li>
<li><strong>C 才合理的情境</strong>：確定不要的探索、且確認不需要未來參考</li>
</ul>
<h3 id="d直接刪檔案不留-git-紀錄">D：直接刪檔案、不留 git 紀錄</h3>
<ul>
<li><strong>機制</strong>：手動刪 / 改、不 commit</li>
<li><strong>成本特別高的原因</strong>：未來想比較或恢復都做不到、使用者選項被收窄</li>
<li><strong>D 是反模式</strong>：git 是免費的紀錄工具、不用反而是浪費 — 未來想比較或恢復都做不到、使用者選項被收窄</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該觸發的處理</th>
          <th>第一個該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「先還原」</td>
          <td>確認還原範圍 + checkpoint</td>
          <td>問「還原到哪」</td>
      </tr>
      <tr>
          <td>「重來」</td>
          <td>確認重做起點 + checkpoint</td>
          <td>問「從哪重來」</td>
      </tr>
      <tr>
          <td>「不要做了」</td>
          <td>評估保留價值</td>
          <td>探索成本高就 checkpoint、否則直接刪</td>
      </tr>
      <tr>
          <td>「先放著」</td>
          <td>Stash 或 branch 保留</td>
          <td>不要刪、要留可恢復路徑</td>
      </tr>
      <tr>
          <td>「換個方法試試」</td>
          <td>Checkpoint + 換方向</td>
          <td>保留現方法的進度</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：退出指令的執行不該收窄使用者的未來選項。Checkpoint = 給未來保留可能性、成本只是多一個 commit。</p>
]]></content:encoded></item><item><title>並行 AI Agent 修改同一檔案的衝突模式與協調策略</title><link>https://tarrragon.github.io/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>多人（或多 agent）並行開發時，如果修改集中在同一個檔案，協調成本可能抵消並行的收益。以下是一個具體案例。&lt;/p>
&lt;p>v0.3.0 的 JS SDK 開發中，五張 ticket 被並行派發給五個 AI agent：flush 邏輯、離線容錯、自動攔截、頁面生命週期、rate limiting。前四個都需要修改同一個檔案 &lt;code>monitor.ts&lt;/code>。&lt;/p>
&lt;p>結果：&lt;/p>
&lt;ul>
&lt;li>三個 agent 回報 branch protection hook 阻擋 src 編輯&lt;/li>
&lt;li>兩個 agent 回報 &lt;code>file modified since read&lt;/code> 拒絕 Edit（另一個 agent 正在寫同一檔案）&lt;/li>
&lt;li>PM 花了多個回合協調 commit 策略：「你先 commit」「你等他完成」「你只 git add 你的檔案」&lt;/li>
&lt;li>最終 PM 手動合併所有 agent 的變更，做了一個統一 commit&lt;/li>
&lt;/ul>
&lt;p>並行派發的目標是縮短總工時。但五個 agent 改同一檔案時，協調成本抵消了並行的收益。&lt;/p>
&lt;h2 id="根因派發粒度錯在-ticket-層而非檔案層">根因：派發粒度錯在 ticket 層而非檔案層&lt;/h2>
&lt;p>派發決策看的是 ticket 的獨立性——五張 ticket 描述的功能確實獨立（flush、離線、攔截、生命週期各自有清楚的邊界）。但獨立的功能不等於獨立的檔案。五個功能的修改都集中在 &lt;code>monitor.ts&lt;/code> 這一個檔案上。&lt;/p>
&lt;p>ticket 獨立 =/= 檔案獨立。並行安全的判斷基準應該是後者。&lt;/p>
&lt;h2 id="教訓">教訓&lt;/h2>
&lt;p>&lt;strong>派發前掃描 &lt;code>where.files&lt;/code>&lt;/strong>：如果多張 ticket 的目標檔案有交集，序列化派發。前一張完成並 commit 後，再派下一張。&lt;/p>
&lt;p>&lt;strong>序列的代價比衝突的代價低&lt;/strong>：五個 agent 序列執行可能需要 5 倍時間，但每個 agent 在乾淨的工作區上操作，不需要協調。五個 agent 並行但衝突，PM 的協調時間加上 agent 的等待和重試，總成本可能更高。&lt;/p>
&lt;p>&lt;strong>Worktree 隔離不是萬靈丹&lt;/strong>：git worktree 讓每個 agent 有獨立的工作目錄，避免 working tree 衝突。但如果兩個 agent 修改同一檔案的不同區段，merge 時仍需人工判斷。Worktree 解決的是「同時寫同一個 working tree」的問題，不解決「同時改同一個檔案的語意衝突」。&lt;/p>
&lt;h2 id="適用場景">適用場景&lt;/h2>
&lt;p>這個 pattern 不限於 AI agent。人類開發者在同一個 Sprint 中被分配修改同一個檔案的不同功能時，也會遇到 merge conflict。差異在於人類可以口頭協調（「我先改完你再改」），agent 目前缺乏這個即時溝通管道。派發者（PM 或 CI 系統）需要在派發時就做好檔案衝突預判。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>多人（或多 agent）並行開發時，如果修改集中在同一個檔案，協調成本可能抵消並行的收益。以下是一個具體案例。</p>
<p>v0.3.0 的 JS SDK 開發中，五張 ticket 被並行派發給五個 AI agent：flush 邏輯、離線容錯、自動攔截、頁面生命週期、rate limiting。前四個都需要修改同一個檔案 <code>monitor.ts</code>。</p>
<p>結果：</p>
<ul>
<li>三個 agent 回報 branch protection hook 阻擋 src 編輯</li>
<li>兩個 agent 回報 <code>file modified since read</code> 拒絕 Edit（另一個 agent 正在寫同一檔案）</li>
<li>PM 花了多個回合協調 commit 策略：「你先 commit」「你等他完成」「你只 git add 你的檔案」</li>
<li>最終 PM 手動合併所有 agent 的變更，做了一個統一 commit</li>
</ul>
<p>並行派發的目標是縮短總工時。但五個 agent 改同一檔案時，協調成本抵消了並行的收益。</p>
<h2 id="根因派發粒度錯在-ticket-層而非檔案層">根因：派發粒度錯在 ticket 層而非檔案層</h2>
<p>派發決策看的是 ticket 的獨立性——五張 ticket 描述的功能確實獨立（flush、離線、攔截、生命週期各自有清楚的邊界）。但獨立的功能不等於獨立的檔案。五個功能的修改都集中在 <code>monitor.ts</code> 這一個檔案上。</p>
<p>ticket 獨立 =/= 檔案獨立。並行安全的判斷基準應該是後者。</p>
<h2 id="教訓">教訓</h2>
<p><strong>派發前掃描 <code>where.files</code></strong>：如果多張 ticket 的目標檔案有交集，序列化派發。前一張完成並 commit 後，再派下一張。</p>
<p><strong>序列的代價比衝突的代價低</strong>：五個 agent 序列執行可能需要 5 倍時間，但每個 agent 在乾淨的工作區上操作，不需要協調。五個 agent 並行但衝突，PM 的協調時間加上 agent 的等待和重試，總成本可能更高。</p>
<p><strong>Worktree 隔離不是萬靈丹</strong>：git worktree 讓每個 agent 有獨立的工作目錄，避免 working tree 衝突。但如果兩個 agent 修改同一檔案的不同區段，merge 時仍需人工判斷。Worktree 解決的是「同時寫同一個 working tree」的問題，不解決「同時改同一個檔案的語意衝突」。</p>
<h2 id="適用場景">適用場景</h2>
<p>這個 pattern 不限於 AI agent。人類開發者在同一個 Sprint 中被分配修改同一個檔案的不同功能時，也會遇到 merge conflict。差異在於人類可以口頭協調（「我先改完你再改」），agent 目前缺乏這個即時溝通管道。派發者（PM 或 CI 系統）需要在派發時就做好檔案衝突預判。</p>
]]></content:encoded></item><item><title>遠端 CLI 開發的 git 線圖工具選型：tig、lazygit、gitui 與管線增強</title><link>https://tarrragon.github.io/blog/linux/tools/cli/git-line-graph-tools-for-remote-cli/</link><pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/cli/git-line-graph-tools-for-remote-cli/</guid><description>&lt;p>git 線圖工具，是把 commit 的分支、合併與時間先後關係畫成終端機可讀圖形的一類程式，承擔的責任是讓開發者在沒有桌面圖形環境的遠端機器上，仍能看清楚 repo 的歷史結構並進行版控操作。在純 SSH 連線的開發情境下，它取代了 IDE 內建的 git 圖形面板，而傳輸的全是文字，所以在頻寬低、只有終端機的條件下依然可用。&lt;/p>
&lt;p>最基本的線圖能力內建在 git 本身：&lt;code>git log --oneline --decorate --graph&lt;/code> 就會用 ASCII 畫出分支線。Oh My Zsh 的 git plugin 把它包成 &lt;code>glog&lt;/code>（當前分支）與 &lt;code>gloga&lt;/code>（加 &lt;code>--all&lt;/code> 看全分支）兩個 alias。這條 alias 是任何環境都成立的底線 — 即使在一台陌生、不能安裝任何東西的機器上，&lt;code>git log --graph&lt;/code> 永遠都在。專用工具要解決的，是這條底線之上的兩個缺口：互動瀏覽的流暢度，以及把「看」與「改」整合在同一畫面。&lt;/p>
&lt;p>本文承接 &lt;a href="https://tarrragon.github.io/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽&lt;/a> 的 TUI 工具脈絡，聚焦 git 線圖這個版控子題（最常被遠端開發者問到）。&lt;/p>
&lt;h2 id="三類工具的職責分工">三類工具的職責分工&lt;/h2>
&lt;p>git 線圖工具依承擔的責任分三類，遠端 CLI 情境下各自適用的條件不同。&lt;/p>
&lt;h3 id="tui-互動式瀏覽與操作">TUI 互動式瀏覽與操作&lt;/h3>
&lt;p>TUI 工具負責把 git 歷史開成全螢幕的互動介面，讓游標在 commit、檔案、分支之間移動，並即時在側欄顯示對應的 diff。它跟單純印一次 log 的差別在於「可導航」— 線圖、diff、blame 在同一個畫面裡用鍵盤切換，不必反覆重打指令。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>語言&lt;/th>
 &lt;th>一句話定位&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>tig&lt;/code>&lt;/td>
 &lt;td>C&lt;/td>
 &lt;td>老牌穩定的唯讀瀏覽器（log/diff/blame）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lazygit&lt;/code>&lt;/td>
 &lt;td>Go&lt;/td>
 &lt;td>功能最全的操作中樞&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>gitui&lt;/code>&lt;/td>
 &lt;td>Rust&lt;/td>
 &lt;td>精簡高效、大 repo 友善&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>已經在用 &lt;code>tig&lt;/code> 的人，可以把後兩者當成「補上操作能力」而非替換：只想瀏覽就停在 &lt;code>tig&lt;/code>，要用鍵盤完成 stage/commit/rebase 再加 &lt;code>lazygit&lt;/code>，兩者互補。各工具的責任邊界與選型條件在下面逐一展開。&lt;/p>
&lt;p>&lt;code>tig&lt;/code> 的責任邊界在「看」。它把 git 歷史做成可導航的唯讀視圖，線圖呈現清楚、資源佔用極低，適合只想快速翻歷史與看 diff 的情境。它本身不做版控操作，所以心智負擔小、學習成本低。&lt;/p>
&lt;p>&lt;code>lazygit&lt;/code> 把責任從「看」擴到「改」。互動式 rebase、cherry-pick、stash 管理、衝突解決、stage 到 commit 的完整流程都能用鍵盤完成，等於把終端機 git 操作整碗端進一個畫面。它的代價是功能多帶來的學習曲線與稍高的資源佔用。&lt;/p>
&lt;p>&lt;code>gitui&lt;/code> 與 &lt;code>lazygit&lt;/code> 定位相近但取捨相反，刻意保持精簡並換取效能。日常的 stage、commit、branch、stash、blame、log 都涵蓋，但進階流程的覆蓋度不追求面面俱到。它跟 &lt;code>lazygit&lt;/code> 的深入比較放在後面一節。&lt;/p>
&lt;h3 id="純-log-線圖增強走管線零-tui">純 log 線圖增強（走管線、零 TUI）&lt;/h3>
&lt;p>這類工具不開全螢幕介面，而是改善 &lt;code>git log&lt;/code> 一次性輸出的可讀性，責任是讓線圖更清楚、讓 diff 配色更易讀。它走標準輸出與管線，適合接在腳本裡或當成 alias 隨手用。&lt;/p>
&lt;p>&lt;code>git log --graph&lt;/code> 系列（也就是 &lt;code>glog&lt;/code> / &lt;code>gloga&lt;/code>）是這條路線的起點，零安裝、處處可用。&lt;code>git-graph&lt;/code> 是專門產生比內建更清楚的 ASCII 分支線的工具，當內建線圖在複雜合併歷史下變得難讀時，它把分支著色與排版做得更工整。&lt;code>delta&lt;/code> 是 diff 的語法高亮 pager，嚴格說不算線圖工具，但它把 &lt;code>git log -p&lt;/code> 與 &lt;code>git diff&lt;/code> 的輸出做成帶配色、帶行號、可左右並排的版面，常跟前述工具搭配使用 — 後面 lazygit 的 side-by-side diff 就是靠它。&lt;/p>
&lt;p>這類工具的判讀訊號是：需要的是「印一次看一眼」而非持續導航。它對頻寬特別友善，因為是一次性輸出、不像 TUI 會持續重畫畫面。&lt;/p>
&lt;h3 id="桌面-gui遠端通常排除">桌面 GUI（遠端通常排除）&lt;/h3>
&lt;p>&lt;code>gitk&lt;/code>、&lt;code>git-gui&lt;/code>、&lt;code>gitg&lt;/code> 這類桌面圖形工具依賴 X11 或桌面環境，在純終端機的遠端連線下無法直接執行，或需要繁瑣且吃頻寬的 X11 forwarding。這個排除有明確前提：本篇限定「只有終端機、不能在遠端裝 IDE agent」的最小情境。若情境允許 IDE 的 remote 機制（VS Code Remote-SSH、JetBrains Gateway）或可接受 X11 forwarding，桌面 GUI 仍能遠端使用、體驗也不差 — 這條前提放寬時，本篇的結論會跟著變。把 GUI 列在這裡只為說明邊界：它們解決的是「有桌面或 IDE 通道」的需求，與「只有終端機」是不同場景。&lt;/p>
&lt;h2 id="遠端情境為什麼偏好單一-binary">遠端情境為什麼偏好單一 binary&lt;/h2>
&lt;p>遠端開發選型有一個容易被忽略的隱性約束：工具的安裝依賴。Go 與 Rust 寫的工具（&lt;code>lazygit&lt;/code>、&lt;code>gitui&lt;/code>、&lt;code>git-graph&lt;/code>、&lt;code>delta&lt;/code>）通常編譯成單一 binary，相較需要先裝 runtime 的工具，把檔案搬上去就能用，這是它們在 SSH 情境特別受歡迎的原因之一。&lt;/p>
&lt;p>但「單一 binary」要打兩個折扣，照字面 &lt;code>scp&lt;/code> 可能撞牆。其一，binary 自身不含 runtime，不代表沒有執行期依賴：&lt;code>lazygit&lt;/code> 與 &lt;code>gitui&lt;/code> 執行時都會呼叫系統的 &lt;code>git&lt;/code>，遠端機沒裝 git 就跑不動。其二，Rust 工具（&lt;code>gitui&lt;/code> / &lt;code>delta&lt;/code>）預設動態連結 glibc，不是真正的 static；跨發行版或搬進 alpine 容器（用 musl）會出現 &lt;code>GLIBC not found&lt;/code>，這種情境要下載對應的 musl 靜態建置版。判讀的分界是：能用系統套件管理器自由安裝時，依語言寫成什麼影響不大；環境受限時，除了「一個檔案」還要確認目標機有 &lt;code>git&lt;/code>、且 binary 的 libc 對得上目標系統。這也是為什麼 &lt;code>git log --graph&lt;/code> alias 是最後的保命符 — 它連 binary 都不必搬。&lt;/p></description><content:encoded><![CDATA[<p>git 線圖工具，是把 commit 的分支、合併與時間先後關係畫成終端機可讀圖形的一類程式，承擔的責任是讓開發者在沒有桌面圖形環境的遠端機器上，仍能看清楚 repo 的歷史結構並進行版控操作。在純 SSH 連線的開發情境下，它取代了 IDE 內建的 git 圖形面板，而傳輸的全是文字，所以在頻寬低、只有終端機的條件下依然可用。</p>
<p>最基本的線圖能力內建在 git 本身：<code>git log --oneline --decorate --graph</code> 就會用 ASCII 畫出分支線。Oh My Zsh 的 git plugin 把它包成 <code>glog</code>（當前分支）與 <code>gloga</code>（加 <code>--all</code> 看全分支）兩個 alias。這條 alias 是任何環境都成立的底線 — 即使在一台陌生、不能安裝任何東西的機器上，<code>git log --graph</code> 永遠都在。專用工具要解決的，是這條底線之上的兩個缺口：互動瀏覽的流暢度，以及把「看」與「改」整合在同一畫面。</p>
<p>本文承接 <a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a> 的 TUI 工具脈絡，聚焦 git 線圖這個版控子題（最常被遠端開發者問到）。</p>
<h2 id="三類工具的職責分工">三類工具的職責分工</h2>
<p>git 線圖工具依承擔的責任分三類，遠端 CLI 情境下各自適用的條件不同。</p>
<h3 id="tui-互動式瀏覽與操作">TUI 互動式瀏覽與操作</h3>
<p>TUI 工具負責把 git 歷史開成全螢幕的互動介面，讓游標在 commit、檔案、分支之間移動，並即時在側欄顯示對應的 diff。它跟單純印一次 log 的差別在於「可導航」— 線圖、diff、blame 在同一個畫面裡用鍵盤切換，不必反覆重打指令。</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>語言</th>
          <th>一句話定位</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>tig</code></td>
          <td>C</td>
          <td>老牌穩定的唯讀瀏覽器（log/diff/blame）</td>
      </tr>
      <tr>
          <td><code>lazygit</code></td>
          <td>Go</td>
          <td>功能最全的操作中樞</td>
      </tr>
      <tr>
          <td><code>gitui</code></td>
          <td>Rust</td>
          <td>精簡高效、大 repo 友善</td>
      </tr>
  </tbody>
</table>
<p>已經在用 <code>tig</code> 的人，可以把後兩者當成「補上操作能力」而非替換：只想瀏覽就停在 <code>tig</code>，要用鍵盤完成 stage/commit/rebase 再加 <code>lazygit</code>，兩者互補。各工具的責任邊界與選型條件在下面逐一展開。</p>
<p><code>tig</code> 的責任邊界在「看」。它把 git 歷史做成可導航的唯讀視圖，線圖呈現清楚、資源佔用極低，適合只想快速翻歷史與看 diff 的情境。它本身不做版控操作，所以心智負擔小、學習成本低。</p>
<p><code>lazygit</code> 把責任從「看」擴到「改」。互動式 rebase、cherry-pick、stash 管理、衝突解決、stage 到 commit 的完整流程都能用鍵盤完成，等於把終端機 git 操作整碗端進一個畫面。它的代價是功能多帶來的學習曲線與稍高的資源佔用。</p>
<p><code>gitui</code> 與 <code>lazygit</code> 定位相近但取捨相反，刻意保持精簡並換取效能。日常的 stage、commit、branch、stash、blame、log 都涵蓋，但進階流程的覆蓋度不追求面面俱到。它跟 <code>lazygit</code> 的深入比較放在後面一節。</p>
<h3 id="純-log-線圖增強走管線零-tui">純 log 線圖增強（走管線、零 TUI）</h3>
<p>這類工具不開全螢幕介面，而是改善 <code>git log</code> 一次性輸出的可讀性，責任是讓線圖更清楚、讓 diff 配色更易讀。它走標準輸出與管線，適合接在腳本裡或當成 alias 隨手用。</p>
<p><code>git log --graph</code> 系列（也就是 <code>glog</code> / <code>gloga</code>）是這條路線的起點，零安裝、處處可用。<code>git-graph</code> 是專門產生比內建更清楚的 ASCII 分支線的工具，當內建線圖在複雜合併歷史下變得難讀時，它把分支著色與排版做得更工整。<code>delta</code> 是 diff 的語法高亮 pager，嚴格說不算線圖工具，但它把 <code>git log -p</code> 與 <code>git diff</code> 的輸出做成帶配色、帶行號、可左右並排的版面，常跟前述工具搭配使用 — 後面 lazygit 的 side-by-side diff 就是靠它。</p>
<p>這類工具的判讀訊號是：需要的是「印一次看一眼」而非持續導航。它對頻寬特別友善，因為是一次性輸出、不像 TUI 會持續重畫畫面。</p>
<h3 id="桌面-gui遠端通常排除">桌面 GUI（遠端通常排除）</h3>
<p><code>gitk</code>、<code>git-gui</code>、<code>gitg</code> 這類桌面圖形工具依賴 X11 或桌面環境，在純終端機的遠端連線下無法直接執行，或需要繁瑣且吃頻寬的 X11 forwarding。這個排除有明確前提：本篇限定「只有終端機、不能在遠端裝 IDE agent」的最小情境。若情境允許 IDE 的 remote 機制（VS Code Remote-SSH、JetBrains Gateway）或可接受 X11 forwarding，桌面 GUI 仍能遠端使用、體驗也不差 — 這條前提放寬時，本篇的結論會跟著變。把 GUI 列在這裡只為說明邊界：它們解決的是「有桌面或 IDE 通道」的需求，與「只有終端機」是不同場景。</p>
<h2 id="遠端情境為什麼偏好單一-binary">遠端情境為什麼偏好單一 binary</h2>
<p>遠端開發選型有一個容易被忽略的隱性約束：工具的安裝依賴。Go 與 Rust 寫的工具（<code>lazygit</code>、<code>gitui</code>、<code>git-graph</code>、<code>delta</code>）通常編譯成單一 binary，相較需要先裝 runtime 的工具，把檔案搬上去就能用，這是它們在 SSH 情境特別受歡迎的原因之一。</p>
<p>但「單一 binary」要打兩個折扣，照字面 <code>scp</code> 可能撞牆。其一，binary 自身不含 runtime，不代表沒有執行期依賴：<code>lazygit</code> 與 <code>gitui</code> 執行時都會呼叫系統的 <code>git</code>，遠端機沒裝 git 就跑不動。其二，Rust 工具（<code>gitui</code> / <code>delta</code>）預設動態連結 glibc，不是真正的 static；跨發行版或搬進 alpine 容器（用 musl）會出現 <code>GLIBC not found</code>，這種情境要下載對應的 musl 靜態建置版。判讀的分界是：能用系統套件管理器自由安裝時，依語言寫成什麼影響不大；環境受限時，除了「一個檔案」還要確認目標機有 <code>git</code>、且 binary 的 libc 對得上目標系統。這也是為什麼 <code>git log --graph</code> alias 是最後的保命符 — 它連 binary 都不必搬。</p>
<h2 id="lazygit-與-gitui-的定位差異">lazygit 與 gitui 的定位差異</h2>
<p><code>lazygit</code> 與 <code>gitui</code> 表面功能重疊度很高，選擇依據主要落在以下幾個面向，而非單純「誰比較快」。</p>
<h3 id="技術底色效能與資源">技術底色：效能與資源</h3>
<p><code>gitui</code> 用 Rust 做了非同步架構，在 monorepo、歷史很長、或機器資源有限（老舊伺服器、容器內）時反應更跟手，啟動極快、記憶體佔用低。<code>lazygit</code> 的效能日常夠用，但在 diff 或 log 非常大時偶有卡頓、記憶體佔用較高。這是兩者最常被提到的分水嶺，也直接對應遠端機器的強弱。</p>
<h3 id="功能廣度-vs-功能聚焦">功能廣度 vs 功能聚焦</h3>
<p>這是比效能更根本的定位差異。<code>lazygit</code> 賭功能廣度：互動式 rebase、cherry-pick、stash 管理、衝突解決、自訂指令幾乎都包了，目標是讓人完全不打 git 指令。<code>gitui</code> 賭功能聚焦：涵蓋 stage、commit、branch、stash、blame、log 這些日常約八成的操作，進階流程（複雜 rebase）的覆蓋度刻意保留，設計上傾向不做太重的事。</p>
<h3 id="選型決策邏輯">選型決策邏輯</h3>
<p>兩者背後是兩種不同的使用意圖。傾向 <code>lazygit</code> 的，是想用一個工具取代 git CLI、把版控操作整碗端進終端機，願意付稍高的資源代價換廣度與便利。傾向 <code>gitui</code> 的，是想要一個快速的 git 視窗，主要看狀態、看歷史、做基本提交，要求即開即用、進階操作仍回去打 git 指令。一句話收斂：<code>lazygit</code> 押廣度與便利，<code>gitui</code> 押速度與輕量。</p>
<h3 id="生態與社群">生態與社群</h3>
<p><code>lazygit</code> 社群採用度較高、star 數較多、教學與設定檔分享資源豐富，keybinding 與自訂指令的客製空間大。<code>gitui</code> 社群較小但穩定，定位清晰。對需要大量客製或想參考他人設定的情境，<code>lazygit</code> 的生態是實質優勢。長期依賴前也值得瞄一眼維護活躍度（release 節奏決定 bug 修復速度）— 兩者都在活躍維護，但 star 數高不等於修得快，這跟社群熱度是兩件事。</p>
<h2 id="選型判準遠端-cli-情境">選型判準（遠端 CLI 情境）</h2>
<p>把上述收斂成一條判準鏈，對應遠端開發的機器條件：</p>
<ul>
<li>機器資源充足、想要一個工具搞定所有 git 操作：選 <code>lazygit</code>，把它當操作中樞。</li>
<li>遠端機器較弱、repo 很大、或只想快速看狀態做提交：選 <code>gitui</code>，換取即開即用與低資源。粗略 tripwire：repo 歷史上萬筆 / monorepo、機器 RAM 約 1GB 以下、或 <code>lazygit</code> 開大 diff 時明顯卡頓，就往 <code>gitui</code> 靠。</li>
<li>只需要看歷史與 diff、不在工具裡做版控操作：<code>tig</code> 的唯讀定位最輕量。</li>
<li>環境受限、不能安裝：退回 <code>gloga</code>（<code>git log --graph --all</code>），它在任何 git 環境都成立。</li>
</ul>
<p>這四者能共存。常見的搭配是 <code>tig</code> 看歷史 + <code>lazygit</code> 做操作，兩者互補性高；<code>gitui</code> 與 <code>tig</code> 的瀏覽定位略有重疊，同時留兩個的理由較弱。風險與邊界在於學習成本：操作中樞型工具按一個鍵就改動 repo，初期適合先在拋棄式分支或測試 repo 練手，熟悉後再用到開發分支。</p>
<h2 id="lazygit-上手與-side-by-side-diff">lazygit 上手與 side-by-side diff</h2>
<p><code>lazygit</code> 的介面遵循一個固定心法：左側面板選「對什麼東西操作」、右側看「內容」、底部提示列顯示「當前能按什麼」。底部提示列會隨游標位置動態變化，所以操作不必背全部快捷鍵，迷路時按 <code>?</code> 會叫出當前面板的上下文敏感說明。</p>
<p>入門只需記幾個導航鍵：<code>Tab</code> 或數字 <code>1</code>~<code>5</code> 切換左側面板（Status / Files / Branches / Commits / Stash），方向鍵或 <code>hjkl</code> 在面板與清單內移動，<code>Esc</code> 返回上一層，<code>q</code> 離開。線圖在 <code>Commits</code> 面板（按 <code>4</code>），全分支關係在 <code>Branches</code> 面板（按 <code>3</code>）。三個最常用的日常操作：在 <code>Files</code> 面板用空白鍵 stage / unstage、stage 完按 <code>c</code> 輸入訊息提交、在 <code>Commits</code> 面板選 commit 後右側自動顯示 diff（<code>Enter</code> 進入檔案層級）。</p>
<p>預設的 diff 是單欄 unified（增刪行逐行上下排列）呈現。要做到像 IDE 那樣左右並排（side-by-side）對齊，<code>lazygit</code> 本身沒有內建這個視圖，需要外接 pager。pager 是負責把長輸出分頁、上色顯示的程式（git 預設用 <code>less</code>）；這裡讓 <code>lazygit</code> 把 diff 文字交給外部 pager 上色並重排成並排版面，最常見的搭配是 <code>delta</code>。安裝 <code>delta</code> 後，在 <code>lazygit</code> 設定檔（<code>~/Library/Application Support/lazygit/config.yml</code>，或 <code>~/.config/lazygit/config.yml</code>）指定它當 pager 並開啟並排模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">git</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">pagers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span>- <span class="nt">colorArg</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">      </span><span class="nt">pager</span><span class="p">:</span><span class="w"> </span><span class="l">delta --dark --paging=never --side-by-side</span></span></span></code></pre></div><p><code>--side-by-side</code> 是讓 <code>delta</code> 左右並排的關鍵旗標，<code>--paging=never</code> 讓 <code>delta</code> 只負責上色與排版、捲動分頁仍由 <code>lazygit</code> 處理。<code>git.pagers</code>（list）是現行 lazygit 的設定鍵；舊版的 <code>git.paging.pager</code>（單數）仍可用，新版啟動時會自動 migrate 成上面的形式並改寫設定檔。在窄螢幕（手機、平板遠端）下，並排會把每欄壓得很窄，這種情境改回垂直單欄反而好讀 — side-by-side 的適用條件是螢幕夠寬。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>選型確定後，後續深入的方向：</p>
<ul>
<li>想完全用鍵盤取代 git 指令：深入 <code>lazygit</code> 的互動式 rebase、cherry-pick 與自訂指令流程。</li>
<li>遠端機器資源吃緊：實測 <code>gitui</code> 在大型 repo 的反應，跟 <code>lazygit</code> 同一個 repo 跑一次比較體感。</li>
<li>diff 配色與並排需求延伸到日常 git：把 <code>delta</code> 設成 git 全域 pager（<code>git config --global core.pager delta</code>），讓 <code>git diff</code> 與 <code>git log -p</code> 也吃到同一套配色。</li>
</ul>
<p>git 線圖在整個遠端 CLI 工具選型中的位置，見 <a href="/blog/linux/tools/cli/cli-graphical-tools-overview/" data-link-title="終端機圖形化工具總覽：遠端操作下的 TUI、文字圖表與多工器" data-link-desc="在純文字終端機裡用 ASCII 與製圖字元做出監控儀表板、資料圖表與多視窗操作的工具總覽，並針對 SSH 伺服器、手機平板、低頻寬三種遠端情境給出選型判讀。">終端機圖形化工具總覽</a> — 本篇屬其中的版控子題、與系統監控的 TUI 工具脈絡相承。</p>
]]></content:encoded></item><item><title>Git：git stash 的 -u 參數（連未追蹤檔案一起暫存）</title><link>https://tarrragon.github.io/blog/work-log/gitgit-stash-%E7%9A%84-u-%E5%8F%83%E6%95%B8%E9%80%A3%E6%9C%AA%E8%BF%BD%E8%B9%A4%E6%AA%94%E6%A1%88%E4%B8%80%E8%B5%B7%E6%9A%AB%E5%AD%98/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gitgit-stash-%E7%9A%84-u-%E5%8F%83%E6%95%B8%E9%80%A3%E6%9C%AA%E8%BF%BD%E8%B9%A4%E6%AA%94%E6%A1%88%E4%B8%80%E8%B5%B7%E6%9A%AB%E5%AD%98/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>開新功能時，工作目錄常常同時有「改過的舊檔案」和「全新建立的檔案」。
想用 &lt;code>git stash&lt;/code> 暫時收起來去拉主線變更，卻發現新檔案沒被收進去，
還散在工作目錄裡，導致 rebase / 切分支時出狀況。&lt;/p>
&lt;p>原因是預設的 &lt;code>git stash&lt;/code> &lt;strong>不會收 untracked 檔案&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="-u-是什麼">&lt;code>-u&lt;/code> 是什麼&lt;/h2>
&lt;p>&lt;code>-u&lt;/code> 是 &lt;code>--include-untracked&lt;/code> 的縮寫，&lt;code>u&lt;/code> 就是 &lt;strong>untracked&lt;/strong>（未追蹤檔案）。&lt;/p>
&lt;p>Git 把工作目錄的檔案分成幾種狀態，跟 stash 有關的是這三種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>意思&lt;/th>
 &lt;th>預設 &lt;code>git stash&lt;/code> 會收嗎？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>tracked + modified&lt;/td>
 &lt;td>Git 已追蹤、改過的檔案&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>untracked&lt;/td>
 &lt;td>全新檔案，從未 &lt;code>git add&lt;/code> 過&lt;/td>
 &lt;td>不會（要 &lt;code>-u&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ignored&lt;/td>
 &lt;td>被 &lt;code>.gitignore&lt;/code> 忽略的檔案&lt;/td>
 &lt;td>不會（要 &lt;code>-a&lt;/code> / &lt;code>--all&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「untracked」就是 &lt;code>git status&lt;/code> 裡出現在 &lt;code>Untracked files:&lt;/code> 區塊的新檔案。&lt;/p>
&lt;p>Git 預設不收 untracked，是因為這類檔案常是編譯產物、log、暫存檔，全收進 stash 反而把雜物一起搬動；要求用 &lt;code>-u&lt;/code> 明確表態，是把「要不要連新檔一起暫存」的決定權留給操作者。&lt;code>-a&lt;/code>（&lt;code>--all&lt;/code>）範圍更大，連 &lt;code>.gitignore&lt;/code> 忽略的也一起收，日常少用。&lt;/p>
&lt;hr>
&lt;h2 id="正確流程拉主線變更">正確流程（拉主線變更）&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git stash push -u -m &lt;span class="s2">&amp;#34;暫存&amp;#34;&lt;/span> &lt;span class="c1"># -u 連 untracked 新檔案一起收&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git pull --rebase origin main &lt;span class="c1"># 或 git fetch + git rebase&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">git stash pop &lt;span class="c1"># 把修改倒回工作目錄&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="替代做法">替代做法&lt;/h2>
&lt;p>也可以「先 commit 再 rebase，事後再 &lt;code>git reset --mixed HEAD~1&lt;/code> 把 commit 拆回未提交狀態」。
這個做法會把 untracked 新檔一起收進 commit，省去記得加 &lt;code>-u&lt;/code> 的步驟，適合改動較大、想先有一個完整快照的情況。&lt;/p>
&lt;p>兩者取捨：commit 法會在分支歷史暫時多一顆 commit，rebase 完要記得 &lt;code>reset&lt;/code> 拆回；stash 法把改動收在 &lt;code>refs/stash&lt;/code>、不進分支 log，但 untracked 檔要記得 &lt;code>-u&lt;/code>。日常小改動用 stash 心智負擔較低，改動大或想保留完整快照時用 commit 法。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>開新功能時，工作目錄常常同時有「改過的舊檔案」和「全新建立的檔案」。
想用 <code>git stash</code> 暫時收起來去拉主線變更，卻發現新檔案沒被收進去，
還散在工作目錄裡，導致 rebase / 切分支時出狀況。</p>
<p>原因是預設的 <code>git stash</code> <strong>不會收 untracked 檔案</strong>。</p>
<hr>
<h2 id="-u-是什麼"><code>-u</code> 是什麼</h2>
<p><code>-u</code> 是 <code>--include-untracked</code> 的縮寫，<code>u</code> 就是 <strong>untracked</strong>（未追蹤檔案）。</p>
<p>Git 把工作目錄的檔案分成幾種狀態，跟 stash 有關的是這三種：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>意思</th>
          <th>預設 <code>git stash</code> 會收嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>tracked + modified</td>
          <td>Git 已追蹤、改過的檔案</td>
          <td>會</td>
      </tr>
      <tr>
          <td>untracked</td>
          <td>全新檔案，從未 <code>git add</code> 過</td>
          <td>不會（要 <code>-u</code>）</td>
      </tr>
      <tr>
          <td>ignored</td>
          <td>被 <code>.gitignore</code> 忽略的檔案</td>
          <td>不會（要 <code>-a</code> / <code>--all</code>）</td>
      </tr>
  </tbody>
</table>
<p>「untracked」就是 <code>git status</code> 裡出現在 <code>Untracked files:</code> 區塊的新檔案。</p>
<p>Git 預設不收 untracked，是因為這類檔案常是編譯產物、log、暫存檔，全收進 stash 反而把雜物一起搬動；要求用 <code>-u</code> 明確表態，是把「要不要連新檔一起暫存」的決定權留給操作者。<code>-a</code>（<code>--all</code>）範圍更大，連 <code>.gitignore</code> 忽略的也一起收，日常少用。</p>
<hr>
<h2 id="正確流程拉主線變更">正確流程（拉主線變更）</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git stash push -u -m <span class="s2">&#34;暫存&#34;</span>    <span class="c1"># -u 連 untracked 新檔案一起收</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git pull --rebase origin main  <span class="c1"># 或 git fetch + git rebase</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">git stash pop                  <span class="c1"># 把修改倒回工作目錄</span></span></span></code></pre></div><hr>
<h2 id="替代做法">替代做法</h2>
<p>也可以「先 commit 再 rebase，事後再 <code>git reset --mixed HEAD~1</code> 把 commit 拆回未提交狀態」。
這個做法會把 untracked 新檔一起收進 commit，省去記得加 <code>-u</code> 的步驟，適合改動較大、想先有一個完整快照的情況。</p>
<p>兩者取捨：commit 法會在分支歷史暫時多一顆 commit，rebase 完要記得 <code>reset</code> 拆回；stash 法把改動收在 <code>refs/stash</code>、不進分支 log，但 untracked 檔要記得 <code>-u</code>。日常小改動用 stash 心智負擔較低，改動大或想保留完整快照時用 commit 法。</p>
]]></content:encoded></item><item><title>Commit message vs source code doc：兩份不同職責的文件</title><link>https://tarrragon.github.io/blog/record/commit-message-vs-source-code-doc%E5%85%A9%E4%BB%BD%E4%B8%8D%E5%90%8C%E8%81%B7%E8%B2%AC%E7%9A%84%E6%96%87%E4%BB%B6/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/commit-message-vs-source-code-doc%E5%85%A9%E4%BB%BD%E4%B8%8D%E5%90%8C%E8%81%B7%E8%B2%AC%E7%9A%84%E6%96%87%E4%BB%B6/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心命題&lt;/strong>：source code doc 寫給「未來的讀者」，commit message 寫給「想了解過去發生什麼的考古者」。兩者是不同文件，內容該分開。
&lt;strong>設計原則&lt;/strong>：時序敏感的資訊（為什麼這次改動、考慮過什麼方案）放 commit；持續適用的資訊（當前契約、不變量）放 source。&lt;/p>&lt;/blockquote>
&lt;blockquote>
&lt;p>本篇是 &lt;a href="../function-doc-layered-design/">函式文件分層設計&lt;/a> 反模式 3「過去式 doc」的展開——把「source 跟 commit message 的時序職責邊界」拉成獨立主題討論。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="起點兩份文件的職責容易被混在一起">起點：兩份文件的職責容易被混在一起&lt;/h2>
&lt;p>Source code doc 的職責是「描述當前 code 的契約跟行為」、commit message 的職責是「描述某次改動做了什麼跟為什麼做」——兩者讀者不同、時序屬性不同、本該各歸各家。實務上這兩份文件的職責經常被混在 source code doc 一處：source 變成所有歷史的垃圾桶、commit message 反而沒人認真寫。&lt;/p>
&lt;p>實務上常看到的污染：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">/// 修了 issue #123 的 race condition
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">/// 從 v2.3 開始改用 lock-free 結構
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">/// TODO: @alice 之後可能要改用 SkipList
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">process&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段 doc 混了三類資訊：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>過去發生什麼&lt;/strong>（修了 issue #123）→ 屬於 commit message&lt;/li>
&lt;li>&lt;strong>過去做過什麼決定&lt;/strong>（v2.3 開始改用 lock-free）→ 屬於 commit message / changelog&lt;/li>
&lt;li>&lt;strong>未來可能要改什麼&lt;/strong>（TODO @alice 改用 SkipList）→ 屬於 issue tracker / TODO 系統&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>沒有一條是「未來讀者讀這份 code 需要的資訊」&lt;/strong>——三條都凍結在過去某一刻、source 卻被當成歷史快照在用。要釐清這個問題、得先想清楚兩種文件各自的讀者與時間性。&lt;/p>
&lt;hr>
&lt;h2 id="時序差異當前狀態-vs-狀態轉移">時序差異：當前狀態 vs 狀態轉移&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文件&lt;/th>
 &lt;th>描述什麼&lt;/th>
 &lt;th>寫給誰讀&lt;/th>
 &lt;th>時間性&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Source code doc&lt;/td>
 &lt;td>當前 code 的契約、行為、不變量&lt;/td>
 &lt;td>即將呼叫 / 修改 code 的人&lt;/td>
 &lt;td>&lt;strong>持續適用&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Commit message&lt;/td>
 &lt;td>這次改動做了什麼、為什麼做&lt;/td>
 &lt;td>想了解某個變動的考古者&lt;/td>
 &lt;td>&lt;strong>特定時間點的決定&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵差別是&lt;strong>時間性&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>Source code doc 描述「&lt;strong>現在&lt;/strong>這份 code 在做什麼」——只要 code 不變，doc 就持續有效&lt;/li>
&lt;li>Commit message 描述「&lt;strong>那一刻&lt;/strong>為什麼要改 code」——commit 完成的那一秒就成為歷史&lt;/li>
&lt;/ul>
&lt;p>把過去式的內容塞進 source code doc，會讓 doc 變成「凍結在某個歷史時點的快照」，而不是描述當前狀態。&lt;/p>
&lt;hr>
&lt;h2 id="該寫在-commit-message-的內容">該寫在 commit message 的內容&lt;/h2>
&lt;p>Commit message 的核心職責是回答「&lt;strong>這次改動做了什麼、為什麼做&lt;/strong>」——所有「凍結在某次提交時點」的資訊都應該住在這裡、而不是被塞進 source 變成過時快照。下面四類是最常被誤放進 source 的內容：&lt;/p>
&lt;h3 id="1-改動的動機為什麼這次要動">1. 改動的動機（為什麼這次要動）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">fix: prevent double-charge on payment retry
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Payment gateway 對同一個 transaction_id 會回傳 200 但實際扣款兩次
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">（incident #4521）。在 client 端加上 idempotency_key，gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">看到重複的 key 直接回 cached response。&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「為什麼動」幾乎永遠屬於 commit message。&lt;strong>source code 只需要描述「現在的行為是什麼」，不需要解釋「過去為什麼變成這樣」&lt;/strong>——除非那個「為什麼」對未來呼叫者仍是必須知道的限制（見後面段落）。&lt;/p>
&lt;h3 id="2-評估過的替代方案why-not-x">2. 評估過的替代方案（why not X）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">refactor: replace stream with reactive value
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">考慮過三個方案：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">- A. 改成 broadcast stream：最 minimal，但保留同樣的 payload 語義模糊問題
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">- B. 加新 broadcast stream 平行存在：兩條 stream 容易不同步
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">- C. 拆成 reactive value（採用）：與系統其他 service 一致、消除多訂閱問題
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">選 C 因為與 codebase 其他 service 風格對齊，雖然改動範圍最大。&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「考慮過 A、B、C，選了 C」這類資訊對 reviewer 重要，對未來讀 code 的人多半不重要——他們看到的是 C 的結果，不關心你考慮過 A、B。&lt;strong>這類資訊屬於 commit message / PR description&lt;/strong>，不屬於 source code doc。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心命題</strong>：source code doc 寫給「未來的讀者」，commit message 寫給「想了解過去發生什麼的考古者」。兩者是不同文件，內容該分開。
<strong>設計原則</strong>：時序敏感的資訊（為什麼這次改動、考慮過什麼方案）放 commit；持續適用的資訊（當前契約、不變量）放 source。</p></blockquote>
<blockquote>
<p>本篇是 <a href="../function-doc-layered-design/">函式文件分層設計</a> 反模式 3「過去式 doc」的展開——把「source 跟 commit message 的時序職責邊界」拉成獨立主題討論。</p></blockquote>
<hr>
<h2 id="起點兩份文件的職責容易被混在一起">起點：兩份文件的職責容易被混在一起</h2>
<p>Source code doc 的職責是「描述當前 code 的契約跟行為」、commit message 的職責是「描述某次改動做了什麼跟為什麼做」——兩者讀者不同、時序屬性不同、本該各歸各家。實務上這兩份文件的職責經常被混在 source code doc 一處：source 變成所有歷史的垃圾桶、commit message 反而沒人認真寫。</p>
<p>實務上常看到的污染：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 修了 issue #123 的 race condition
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">/// 從 v2.3 開始改用 lock-free 結構
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">/// TODO: @alice 之後可能要改用 SkipList
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">process</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span></span></span></code></pre></div><p>這段 doc 混了三類資訊：</p>
<ol>
<li><strong>過去發生什麼</strong>（修了 issue #123）→ 屬於 commit message</li>
<li><strong>過去做過什麼決定</strong>（v2.3 開始改用 lock-free）→ 屬於 commit message / changelog</li>
<li><strong>未來可能要改什麼</strong>（TODO @alice 改用 SkipList）→ 屬於 issue tracker / TODO 系統</li>
</ol>
<p><strong>沒有一條是「未來讀者讀這份 code 需要的資訊」</strong>——三條都凍結在過去某一刻、source 卻被當成歷史快照在用。要釐清這個問題、得先想清楚兩種文件各自的讀者與時間性。</p>
<hr>
<h2 id="時序差異當前狀態-vs-狀態轉移">時序差異：當前狀態 vs 狀態轉移</h2>
<table>
  <thead>
      <tr>
          <th>文件</th>
          <th>描述什麼</th>
          <th>寫給誰讀</th>
          <th>時間性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source code doc</td>
          <td>當前 code 的契約、行為、不變量</td>
          <td>即將呼叫 / 修改 code 的人</td>
          <td><strong>持續適用</strong></td>
      </tr>
      <tr>
          <td>Commit message</td>
          <td>這次改動做了什麼、為什麼做</td>
          <td>想了解某個變動的考古者</td>
          <td><strong>特定時間點的決定</strong></td>
      </tr>
  </tbody>
</table>
<p>關鍵差別是<strong>時間性</strong>：</p>
<ul>
<li>Source code doc 描述「<strong>現在</strong>這份 code 在做什麼」——只要 code 不變，doc 就持續有效</li>
<li>Commit message 描述「<strong>那一刻</strong>為什麼要改 code」——commit 完成的那一秒就成為歷史</li>
</ul>
<p>把過去式的內容塞進 source code doc，會讓 doc 變成「凍結在某個歷史時點的快照」，而不是描述當前狀態。</p>
<hr>
<h2 id="該寫在-commit-message-的內容">該寫在 commit message 的內容</h2>
<p>Commit message 的核心職責是回答「<strong>這次改動做了什麼、為什麼做</strong>」——所有「凍結在某次提交時點」的資訊都應該住在這裡、而不是被塞進 source 變成過時快照。下面四類是最常被誤放進 source 的內容：</p>
<h3 id="1-改動的動機為什麼這次要動">1. 改動的動機（為什麼這次要動）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">fix: prevent double-charge on payment retry
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">Payment gateway 對同一個 transaction_id 會回傳 200 但實際扣款兩次
</span></span><span class="line"><span class="ln">4</span><span class="cl">（incident #4521）。在 client 端加上 idempotency_key，gateway
</span></span><span class="line"><span class="ln">5</span><span class="cl">看到重複的 key 直接回 cached response。</span></span></code></pre></div><p>「為什麼動」幾乎永遠屬於 commit message。<strong>source code 只需要描述「現在的行為是什麼」，不需要解釋「過去為什麼變成這樣」</strong>——除非那個「為什麼」對未來呼叫者仍是必須知道的限制（見後面段落）。</p>
<h3 id="2-評估過的替代方案why-not-x">2. 評估過的替代方案（why not X）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">refactor: replace stream with reactive value
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">考慮過三個方案：
</span></span><span class="line"><span class="ln">4</span><span class="cl">- A. 改成 broadcast stream：最 minimal，但保留同樣的 payload 語義模糊問題
</span></span><span class="line"><span class="ln">5</span><span class="cl">- B. 加新 broadcast stream 平行存在：兩條 stream 容易不同步
</span></span><span class="line"><span class="ln">6</span><span class="cl">- C. 拆成 reactive value（採用）：與系統其他 service 一致、消除多訂閱問題
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl">選 C 因為與 codebase 其他 service 風格對齊，雖然改動範圍最大。</span></span></code></pre></div><p>「考慮過 A、B、C，選了 C」這類資訊對 reviewer 重要，對未來讀 code 的人多半不重要——他們看到的是 C 的結果，不關心你考慮過 A、B。<strong>這類資訊屬於 commit message / PR description</strong>，不屬於 source code doc。</p>
<h3 id="3-migration--部署相關步驟">3. Migration / 部署相關步驟</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">feat: migrate user_profile from int_id to uuid
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">注意：
</span></span><span class="line"><span class="ln">4</span><span class="cl">- 跑 migration 0042 之前先確認所有 client 已升到 v3.2 以上
</span></span><span class="line"><span class="ln">5</span><span class="cl">- migration 預估 2 小時（10M rows），建議週末執行
</span></span><span class="line"><span class="ln">6</span><span class="cl">- rollback：reverse migration 0042 然後 redeploy v3.1</span></span></code></pre></div><p>部署時序與步驟是當下發布動作的一部分，commit / release notes 該寫；source code 不該背這個負擔。</p>
<h3 id="4-bug-號ticket-連結incident-紀錄">4. Bug 號、ticket 連結、incident 紀錄</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">fix: handle empty cart in checkout button visibility
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">Closes <span class="c1">#1234</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">Related: incident-2026-04-12 <span class="o">(</span>button stuck enabled<span class="o">)</span></span></span></code></pre></div><p>把 ticket 號 / issue 連結寫在 commit message，git blame 出來的 commit 直接帶你去原始討論。寫在 source code 反而會 outdated（issue 關了、tracker 換了、URL 改了）。</p>
<hr>
<h2 id="該寫在-source-code-doc-的內容">該寫在 source code doc 的內容</h2>
<p>Source code doc 的核心職責是描述「<strong>當前 code 的契約跟行為</strong>」——只要 code 不變、doc 就持續有效。下面四類是「持續適用」的資訊類別、屬於 source 的家：</p>
<h3 id="1-當前對外契約">1. 當前對外契約</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 從本地購物車移除指定商品
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">///
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">/// 找不到對應品項時不做事；不會拋例外。
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">removeFromLocalCart</span><span class="p">(</span><span class="n">CartItem</span> <span class="n">item</span><span class="p">);</span></span></span></code></pre></div><p>這是「現在這個 function 對 caller 承諾什麼」——持續適用，跟「上週為什麼加這個 function」無關。</p>
<h3 id="2-隱性需求--必要的呼叫順序">2. 隱性需求 / 必要的呼叫順序</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 必須在 [init] 之後呼叫；否則 throw `StateError`。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">process</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span></span></span></code></pre></div><p>「呼叫順序」是當前 code 的契約限制，未來呼叫者必須遵守。屬於 source code doc。</p>
<h3 id="3-對未來讀者仍然重要的過去原因">3. 對未來讀者仍然重要的「過去原因」</h3>
<p>少數情況下，「為什麼以前這樣決定」對未來讀者<strong>仍是必要資訊</strong>——典型是「這個寫法看起來怪，但有非顯然的原因」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kt">void</span> <span class="n">processPayment</span><span class="p">(</span><span class="n">Payment</span> <span class="n">p</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// 刻意不 retry —— payment gateway 是非冪等，retry 會造成重複扣款
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// （見 incident-2026-04-12）。失敗一律拋給上層人工處理。
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="n">_gateway</span><span class="p">.</span><span class="n">charge</span><span class="p">(</span><span class="n">p</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這條註解兼具「歷史原因」和「持續適用的限制」——未來維護者看到這段 code 會想「為什麼沒 retry？」，這條註解防止他「順手加上」。<strong>這類兼具兩種性質的內容是少數該留在 source 的歷史相關 doc</strong>。</p>
<p>判斷標準：「未來讀者<strong>不知道這條歷史會做錯決定</strong>嗎？」</p>
<ul>
<li>是 → 留 source</li>
<li>不是 → 留 commit</li>
</ul>
<h3 id="4-不變量--invariant">4. 不變量 / invariant</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">class</span> <span class="nc">CircularBuffer</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">/// 元素數量永遠在 [0, capacity] 之間
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kt">int</span> <span class="kd">get</span> <span class="n">length</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不變量是「這個型別永遠成立的事實」，是契約的一部分，屬於 source。</p>
<hr>
<h2 id="反模式">反模式</h2>
<h3 id="反模式-1把-commit-message-內容塞進-source">反模式 1：把 commit message 內容塞進 source</h3>
<p><strong>正向概念</strong>：source code doc 描述「現在的行為」、git log 才是「歷史演進」的家。兩者各自有對應的工具（IDE 看 doc、<code>git log</code> 看演進）、各司其職就能讓兩邊都精準。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 反：寫成歷史紀錄
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">/// 2024-01-15 加上 retry 邏輯
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">/// 2024-03-22 改用 exponential backoff
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">/// 2024-07-08 加上 jitter 避免 thundering herd
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">Response</span><span class="o">&gt;</span> <span class="n">fetch</span><span class="p">(</span><span class="kt">String</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 正：source 只寫當前行為
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">/// 自動 retry 失敗的請求，使用 exponential backoff + jitter
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">Future</span><span class="o">&lt;</span><span class="n">Response</span><span class="o">&gt;</span> <span class="n">fetch</span><span class="p">(</span><span class="kt">String</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="o">//</span> <span class="err">演進歷史在</span> <span class="n">git</span> <span class="n">log</span> <span class="err">看</span></span></span></code></pre></div><p>把所有歷史塞進 source 等於在 source code 重做一份 git log——但 git log 已經存在、且結構化、可搜尋、有 author / timestamp。重做一份在 source 只會 outdated（下次再加邏輯時忘了補日期就破功）、而 git log 永遠是同步的。</p>
<h3 id="反模式-2commit-message-只寫-update--fix">反模式 2：commit message 只寫 &ldquo;update&rdquo; / &ldquo;fix&rdquo;</h3>
<p><strong>正向概念</strong>：commit message 是給未來考古者的線索——<code>git blame</code> 跳到一個 commit 時、message 是讀者拿到的第一份資訊。寫得清楚、考古路徑就短；寫得模糊、考古者得繼續挖 PR / 找原作者問。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">- update
</span></span><span class="line"><span class="ln">2</span><span class="cl">- fix
</span></span><span class="line"><span class="ln">3</span><span class="cl">- wip
</span></span><span class="line"><span class="ln">4</span><span class="cl">- final
</span></span><span class="line"><span class="ln">5</span><span class="cl">- final v2
</span></span><span class="line"><span class="ln">6</span><span class="cl">- final v2 真的</span></span></code></pre></div><p>這類 commit message 當下就沒人看得懂、半年後 <code>git blame</code> 把人帶到 message 寫 &ldquo;update&rdquo; 的 commit、等於把讀者帶到死巷。合理 commit message 的最小單位是 <code>&lt;type&gt;: &lt;one-line summary&gt;</code>、例如 <code>fix: handle empty cart in checkout</code>——一行就好、但要說清楚做了什麼。</p>
<h3 id="反模式-3source-code-doc-寫滿-todo--fixme">反模式 3：source code doc 寫滿 TODO / FIXME</h3>
<p><strong>正向概念</strong>：「想未來改但還沒改」屬於 issue tracker——issue tracker 有優先序、有 owner、有 due date、能被排程。source code 的 TODO 沒有這些屬性、會被慢慢遺忘。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// TODO: refactor to use streams
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">/// FIXME: handle null case
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">/// HACK: temporary workaround for issue #234
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">/// XXX: this is broken under high load
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">doSomething</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span></span></span></code></pre></div><p>這些都是「想未來改但還沒改」的事——把它們留在 source 有三個問題：</p>
<ul>
<li>TODO 在 source 不會被 prioritize（產品 / 專案管理工具看不到 source 內的 TODO）</li>
<li>FIXME 在 source 容易被忽略（讀的人會想「不是我寫的不是我的問題」）</li>
<li>HACK / XXX 警告<strong>只在第一次讀時有效</strong>、第二次讀的人會麻木</li>
</ul>
<p>問題嚴重需要立刻處理 → 開 ticket、commit fix；不嚴重可以等 → 開 backlog ticket、source 別寫。把待辦項從 source 搬到 issue tracker、會被真正當成「待辦」處理。</p>
<h3 id="反模式-4把-pr-description-抄一份進-source">反模式 4：把 PR description 抄一份進 source</h3>
<p><strong>正向概念</strong>：PR description 是「這次提交的時空快照」、source code doc 是「持續適用的當前契約」。兩者描述的是同一段 code 在不同時序下的不同切面、各自有對應的家。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 這個 function 是為了支援新的 multi-currency 結帳流程。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">/// 詳細需求見 PR #4521 與設計文件 https://wiki.../...
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">/// 業務需求：客戶可以混合多幣別商品結帳，結帳當下統一換算成 settlement currency。
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">/// QA 已驗證 5 種主要幣別組合 + 邊界 case。
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">multiCurrencyCheckout</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span></span></span></code></pre></div><p>PR description 該寫的內容（業務脈絡、設計連結、QA 範圍）抄進 source、會讓 source 凍結在「<strong>這次新增時的時空狀態</strong>」——半年後 PR 已經是歷史、連結可能失效、QA 範圍可能擴展、但 source 還停在那一刻。PR description 留在 PR、source 只寫 function 當前的對外契約。</p>
<hr>
<h2 id="git-blame-archaeology-workflow">Git blame archaeology workflow</h2>
<p>當 source code doc 跟 commit message 各司其職時，<strong>考古工作流</strong>會變得清晰：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">讀者看到一段 code 不懂為什麼這樣寫
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">先看 source code doc
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">不夠 → 跑 git blame
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">找到引入這段 code 的 commit
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">讀 commit message
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ↓
</span></span><span class="line"><span class="ln">11</span><span class="cl">不夠 → 點進去看完整 PR / issue</span></span></code></pre></div><p>這個工作流要能順利跑，前提是：</p>
<ol>
<li><strong>commit 顆粒度合理</strong>——一個 commit 一個邏輯改動，不要「fix typo + refactor + add feature」混在一起，否則 blame 出來看到一個改 50 個檔案的 commit，message 寫 &ldquo;stuff&rdquo;，等於沒線索</li>
<li><strong>commit message 寫清楚動機</strong>——不是「changed X」（git diff 看得出來），而是「changed X <strong>because Y</strong>」</li>
<li><strong>重大決定用 PR 描述補充</strong>——commit message 太長不適合塞長文，PR description 是放長文的地方</li>
</ol>
<p>如果這三點做到，未來讀 code 的人有一條清楚的考古路徑，不必逼 source code doc 背所有歷史。</p>
<hr>
<h2 id="一個分配工具">一個分配工具</h2>
<p>決定一條資訊放哪時，問三個問題：</p>
<ol>
<li><strong>「未來讀者不知道這條會做錯決定嗎？」</strong>
<ul>
<li>是 → source code doc</li>
<li>不是 → commit message</li>
</ul>
</li>
<li><strong>「這條描述的是當前的行為，還是某次轉移？」</strong>
<ul>
<li>當前行為 → source code doc</li>
<li>某次轉移 → commit message</li>
</ul>
</li>
<li><strong>「Code 改了，這條會不會 outdated？」</strong>
<ul>
<li>不會（描述當前狀態）→ source code doc</li>
<li>會（描述特定時間點）→ commit message</li>
</ul>
</li>
</ol>
<p>三個問題收斂到同一個直覺：<strong>「凍結在過去」屬於 commit、「持續適用」屬於 source</strong>。</p>
<hr>
<h2 id="邊界什麼時候-source-還是該帶歷史脈絡">邊界：什麼時候 source 還是該帶歷史脈絡</h2>
<p>「歷史進 commit、契約進 source」是預設、<strong>但有些情境 source 還是該保留歷史脈絡</strong>——共通特徵是「未來讀者不知道這段歷史會做錯決定」：</p>
<ul>
<li><strong>看似怪、但有非顯然原因的寫法</strong>：「刻意不 retry、payment gateway 是非冪等」——下個維護者順手加 retry 會出事</li>
<li><strong>跟非預期外部行為對齊的 workaround</strong>：「拆兩步 query 避開 SQLite 32-bit Android 的 integer overflow（issue #1234）」——讀者重構時會想「為什麼不一次查」</li>
<li><strong>保留某段 code 的合規 / 法務原因</strong>：「依 GDPR 留 30 天可恢復、不是直接刪」——縮短到 7 天會違反法規</li>
<li><strong>效能調優的非顯然參數</strong>：「batch size = 32 是 production 跑出來的甜蜜點、改大會 OOM」——下次 review 看到「為什麼不開大」時得知道過去的實驗結果</li>
</ul>
<p>判斷標準：「未來讀者<strong>不知道這條歷史就會做錯決定</strong>嗎？」答「是」就留在 source、答「不是」就留在 commit。</p>
<hr>
<h2 id="一句話-heuristic">一句話 heuristic</h2>
<p>把整個討論濃縮：</p>
<blockquote>
<p>Source code doc 寫給「<strong>正要動這段 code 的人</strong>」、commit message 寫給「<strong>想知道為什麼當初這樣寫的人</strong>」。</p></blockquote>
<p>寫東西之前先問：我寫這段，是要幫<strong>正要動 code 的人</strong>做對決定，還是要幫<strong>回顧歷史的人</strong>理解某次改動？兩個讀者要找的資訊不同，分成兩處寫，雙方都受惠。</p>
<hr>
<h2 id="收束兩份文件協同源頭就要分清楚">收束：兩份文件協同，源頭就要分清楚</h2>
<p>很多團隊抱怨「source code doc 太亂、commit message 沒人寫」，本質是這兩份文件的職責沒分清楚。Source 想包辦所有事就會充滿過時內容；commit message 沒人寫是因為「反正歷史會寫進 source」變成預設。</p>
<p>把兩者的職責分清楚，兩份文件都會變健康：</p>
<ul>
<li><strong>source 變短、變精準</strong>：只寫當前契約，doc 不會 outdated</li>
<li><strong>commit message 被認真寫</strong>：因為它是某些資訊的唯一家</li>
<li><strong>考古路徑清楚</strong>：blame → commit → PR 是可預期的回溯路徑</li>
</ul>
<p>寫 doc / 寫 commit 是同一個技能的兩面。不要把任何一邊當成另一邊的替代品。</p>
]]></content:encoded></item><item><title>Git：把後面 commit 的部分檔案變更搬到前面的 commit</title><link>https://tarrragon.github.io/blog/work-log/git_move_partial_change_to_earlier_commit/</link><pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/git_move_partial_change_to_earlier_commit/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>開發到一半，發現 commit log 裡「實作功能」這個 commit 不只改了功能檔案，還包含了「定義 model」階段的檔案變更。這些變更在開發節奏上應該屬於前面的 commit，但實際上被混在後面的 commit 裡。想把這部分檔案的變更從後面那個 commit 抽出來，合併到前面的 commit 裡，&lt;strong>其他檔案保持原狀&lt;/strong>。&lt;/p>
&lt;h3 id="範例">範例&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">gitGraph
 commit id: &amp;#34;A (model 定義)&amp;#34;
 commit id: &amp;#34;B (其他)&amp;#34;
 commit id: &amp;#34;C (功能實作)&amp;#34;
 commit id: &amp;#34;D (其他)&amp;#34;&lt;/code>&lt;/pre>&lt;p>&lt;strong>四個 commit 的角色&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A&lt;/strong>（接收目標）：commit C 中對 &lt;code>models/foo.dart&lt;/code> 的修訂應該屬於這裡&lt;/li>
&lt;li>&lt;strong>B&lt;/strong>（中間插入）：A 跟 C 之間有別的 commit，不能簡單 squash&lt;/li>
&lt;li>&lt;strong>C&lt;/strong>（變更來源）：同時改了 &lt;code>models/foo.dart&lt;/code> 和其他 6 個檔案&lt;/li>
&lt;li>&lt;strong>限制&lt;/strong>：只想搬走 &lt;code>models/foo.dart&lt;/code>，其他檔案保持原狀&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="解法核心">解法核心&lt;/h2>
&lt;p>這個方法的核心靠 &lt;strong>3-way merge 自動跳過重複變更&lt;/strong> — 不需要手動從 C 移除檔案變更，git 會自己偵測「這個變更已在新 base」自動處理。&lt;/p>
&lt;p>具體利用兩個 git 機制：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>git rebase -i&lt;/code> 搭配 &lt;code>edit&lt;/code>&lt;/strong>：在指定 commit 暫停，讓我們手動修改該 commit 的內容&lt;/li>
&lt;li>&lt;strong>3-way merge 自動 dedup&lt;/strong>：當後續 commit 被 replay 時，git 比較三個版本（base / theirs / mine），發現該檔案變更已在 mine 裡，就自動跳過&lt;/li>
&lt;/ol>
&lt;h3 id="為什麼是這個方法">為什麼是這個方法&lt;/h3>
&lt;p>其他可能方案的成本：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>git cherry-pick&lt;/code> 後手動解衝突&lt;/strong>：cherry-pick 會把整個 commit（包含所有檔案）複製到 A 後面，然後再手動移除不需要的變更。多出一個 commit，且流程較長&lt;/li>
&lt;li>&lt;strong>用 &lt;code>git format-patch&lt;/code> 提取單一檔案&lt;/strong>：format-patch 提取的是整個 commit 的 patch，無法只選擇某個檔案。需要手動編輯 patch 檔，失敗風險高&lt;/li>
&lt;li>&lt;strong>直接 &lt;code>git add -p&lt;/code> 重新 commit&lt;/strong>：需要回到 A 後重新手動 commit 一遍，工作量大且容易遺漏&lt;/li>
&lt;/ul>
&lt;p>這個方法的優勢：&lt;strong>自動化程度最高&lt;/strong> — 只需在 A 和 C 時暫停，git 會在 replay C 時自動判斷哪些變更要跳過，無需手動識別衝突或編輯 patch。&lt;/p>
&lt;hr>
&lt;h2 id="步驟">步驟&lt;/h2>
&lt;h3 id="1-建立備份-tag">1. 建立備份 tag&lt;/h3>
&lt;p>歷史改寫前先綁 tag，這樣如果出現預期外的結果可以快速復原。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git tag backup-before-rebase HEAD&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-進入-interactive-rebase把-a-跟-c-都標成-edit">2. 進入 interactive rebase，把 A 跟 C 都標成 edit&lt;/h3>
&lt;p>從 A 的父 commit 開始 rebase，並把 A 跟 C 都標為 &lt;code>edit&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 用環境變數注入「自動把 pick 改成 edit」的 sequence editor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nv">GIT_SEQUENCE_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;sed -i.bak \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="s1"> -e &amp;#34;s/^pick \(&amp;lt;A短hash&amp;gt;\)/edit \1/&amp;#34; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="s1"> -e &amp;#34;s/^pick \(&amp;lt;C短hash&amp;gt;\)/edit \1/&amp;#34;&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span>&lt;span class="nv">GIT_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span>git rebase -i &amp;lt;A短hash&amp;gt;^&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>macOS 的 &lt;code>sed -i&lt;/code> 需要加空字串引數（&lt;code>-i ''&lt;/code>）或像上面用 &lt;code>-i.bak&lt;/code> 留 backup 檔。
Linux 的 &lt;code>sed -i&lt;/code> 不需要。
如果更放心用編輯器手動操作，可以拿掉 &lt;code>GIT_EDITOR=true&lt;/code>，讓 rebase 開你慣用的編輯器手動把兩行的 &lt;code>pick&lt;/code> 改成 &lt;code>edit&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>開發到一半，發現 commit log 裡「實作功能」這個 commit 不只改了功能檔案，還包含了「定義 model」階段的檔案變更。這些變更在開發節奏上應該屬於前面的 commit，但實際上被混在後面的 commit 裡。想把這部分檔案的變更從後面那個 commit 抽出來，合併到前面的 commit 裡，<strong>其他檔案保持原狀</strong>。</p>
<h3 id="範例">範例</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (model 定義)&#34;
   commit id: &#34;B (其他)&#34;
   commit id: &#34;C (功能實作)&#34;
   commit id: &#34;D (其他)&#34;</code></pre><p><strong>四個 commit 的角色</strong>：</p>
<ul>
<li><strong>A</strong>（接收目標）：commit C 中對 <code>models/foo.dart</code> 的修訂應該屬於這裡</li>
<li><strong>B</strong>（中間插入）：A 跟 C 之間有別的 commit，不能簡單 squash</li>
<li><strong>C</strong>（變更來源）：同時改了 <code>models/foo.dart</code> 和其他 6 個檔案</li>
<li><strong>限制</strong>：只想搬走 <code>models/foo.dart</code>，其他檔案保持原狀</li>
</ul>
<hr>
<h2 id="解法核心">解法核心</h2>
<p>這個方法的核心靠 <strong>3-way merge 自動跳過重複變更</strong> — 不需要手動從 C 移除檔案變更，git 會自己偵測「這個變更已在新 base」自動處理。</p>
<p>具體利用兩個 git 機制：</p>
<ol>
<li><strong><code>git rebase -i</code> 搭配 <code>edit</code></strong>：在指定 commit 暫停，讓我們手動修改該 commit 的內容</li>
<li><strong>3-way merge 自動 dedup</strong>：當後續 commit 被 replay 時，git 比較三個版本（base / theirs / mine），發現該檔案變更已在 mine 裡，就自動跳過</li>
</ol>
<h3 id="為什麼是這個方法">為什麼是這個方法</h3>
<p>其他可能方案的成本：</p>
<ul>
<li><strong><code>git cherry-pick</code> 後手動解衝突</strong>：cherry-pick 會把整個 commit（包含所有檔案）複製到 A 後面，然後再手動移除不需要的變更。多出一個 commit，且流程較長</li>
<li><strong>用 <code>git format-patch</code> 提取單一檔案</strong>：format-patch 提取的是整個 commit 的 patch，無法只選擇某個檔案。需要手動編輯 patch 檔，失敗風險高</li>
<li><strong>直接 <code>git add -p</code> 重新 commit</strong>：需要回到 A 後重新手動 commit 一遍，工作量大且容易遺漏</li>
</ul>
<p>這個方法的優勢：<strong>自動化程度最高</strong> — 只需在 A 和 C 時暫停，git 會在 replay C 時自動判斷哪些變更要跳過，無需手動識別衝突或編輯 patch。</p>
<hr>
<h2 id="步驟">步驟</h2>
<h3 id="1-建立備份-tag">1. 建立備份 tag</h3>
<p>歷史改寫前先綁 tag，這樣如果出現預期外的結果可以快速復原。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git tag backup-before-rebase HEAD</span></span></code></pre></div><h3 id="2-進入-interactive-rebase把-a-跟-c-都標成-edit">2. 進入 interactive rebase，把 A 跟 C 都標成 edit</h3>
<p>從 A 的父 commit 開始 rebase，並把 A 跟 C 都標為 <code>edit</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 用環境變數注入「自動把 pick 改成 edit」的 sequence editor</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s1">&#39;sed -i.bak \
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;A短hash&gt;\)/edit \1/&#34; \
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;C短hash&gt;\)/edit \1/&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span><span class="nv">GIT_EDITOR</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>git rebase -i &lt;A短hash&gt;^</span></span></code></pre></div><blockquote>
<p>macOS 的 <code>sed -i</code> 需要加空字串引數（<code>-i ''</code>）或像上面用 <code>-i.bak</code> 留 backup 檔。
Linux 的 <code>sed -i</code> 不需要。
如果更放心用編輯器手動操作，可以拿掉 <code>GIT_EDITOR=true</code>，讓 rebase 開你慣用的編輯器手動把兩行的 <code>pick</code> 改成 <code>edit</code>。</p></blockquote>
<p>執行後 git 會在 A 暫停。</p>
<h3 id="3-在-a把-c-中該檔案的版本拉進來amend">3. 在 A：把 C 中該檔案的版本拉進來，amend</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 把 C 那個 commit 對該檔案的最終內容 checkout 到工作區</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git checkout &lt;C短hash&gt; -- path/to/file.dart
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 加入並 amend 到 A</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git add path/to/file.dart
</span></span><span class="line"><span class="ln">6</span><span class="cl">git commit --amend --no-edit
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># 繼續</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">git rebase --continue</span></span></code></pre></div><blockquote>
<p><code>git checkout &lt;commit&gt; -- &lt;path&gt;</code> 會把指定 commit 的該檔案版本放進工作區。
因為 A 是 C 的祖先，C 的版本就是「A 的版本 + C 的 diff」，等於把 C 對該檔案的變更搬到 A。</p></blockquote>
<h3 id="4-在-c確認-git-自動跳過該檔案的變更">4. 在 C：確認 git 自動跳過該檔案的變更</h3>
<p>rebase 繼續後會 replay B、然後在 C 暫停（因為我們也把它標成 edit）。
此時該檔案對 C 的變更應該已被 git 自動跳過，驗證一下：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git show HEAD --stat
</span></span><span class="line"><span class="ln">2</span><span class="cl">git diff HEAD~ HEAD -- path/to/file.dart</span></span></code></pre></div><p>第一個指令的檔案清單<strong>不應該再出現</strong> <code>path/to/file.dart</code>，第二個指令應該是空輸出。</p>
<p>驗證無誤後，git 已自動完成跳過，直接繼續：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git rebase --continue</span></span></code></pre></div><blockquote>
<p><strong>3-way merge 為什麼會自動跳過？</strong>
Replay C 時 git 用 3-way merge：</p>
<ul>
<li><strong>base</strong>（C 的原始父 commit）：該檔案沒變</li>
<li><strong>theirs</strong>（C 原始版本）：該檔案有 X 變更</li>
<li><strong>mine</strong>（amend 後的 A 接續而來的目前 HEAD）：該檔案已經有 X 變更</li>
</ul>
<p>mine 跟 theirs 的最終狀態一致 → git 認定變更已套用，replay 後的 C 對該檔案就是 no-op。</p></blockquote>
<h3 id="5-驗證最終樹狀態跟備份一致">5. 驗證最終樹狀態跟備份一致</h3>
<p>最關鍵的 sanity check：<strong>內容不應該變，只是 commit 邊界移動</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git diff backup-before-rebase HEAD</span></span></code></pre></div><p><strong>輸出必須是空的</strong>。非空就代表有東西被吃掉或多出來，立刻回滾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git reset --hard backup-before-rebase</span></span></code></pre></div><p>確認沒問題後刪 tag：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git tag -d backup-before-rebase</span></span></code></pre></div><hr>
<h2 id="完整指令摘要">完整指令摘要</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 0. 備份</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git tag backup-before-rebase HEAD
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 1. Rebase，把 A 與 C 都標 edit</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s1">&#39;sed -i.bak \
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;A短hash&gt;\)/edit \1/&#34; \
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;C短hash&gt;\)/edit \1/&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span><span class="nv">GIT_EDITOR</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>git rebase -i &lt;A短hash&gt;^
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 2. 在 A：拉檔案、amend、繼續</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">git checkout &lt;C短hash&gt; -- path/to/file.dart
</span></span><span class="line"><span class="ln">13</span><span class="cl">git add path/to/file.dart
</span></span><span class="line"><span class="ln">14</span><span class="cl">git commit --amend --no-edit
</span></span><span class="line"><span class="ln">15</span><span class="cl">git rebase --continue
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 3. 在 C：驗證、繼續（不需要動手）</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">git show HEAD --stat
</span></span><span class="line"><span class="ln">19</span><span class="cl">git rebase --continue
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"># 4. 驗證樹一致</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">git diff backup-before-rebase HEAD   <span class="c1"># 應為空</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"># 5. 清理</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">git tag -d backup-before-rebase</span></span></code></pre></div><hr>
<h2 id="衍伸當變更區段在-a-跟-c-重疊">衍伸：當變更區段在 A 跟 C 重疊</h2>
<p>如果 A 跟 C 對該檔案動的是<strong>同一個區段</strong>（不是這個範例的 non-overlapping），
3-way merge 會跳出衝突，需要手動編輯。流程：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
   A[&#34;在 A amend 完&#34;] --&gt; B[&#34;replay 到 C 衝突&#34;]
   B --&gt; C[&#34;手動編輯衝突檔&#34;]
   C --&gt; D[&#34;git add + git rebase --continue&#34;]

   style B fill:#e53e3e,color:#fff,stroke:#c53030
   style C fill:#dd6b20,color:#fff,stroke:#c05621</code></pre><p><strong>衝突解決原則</strong>：保留 A 已經帶過去的版本（也就是 C 想再套一次但其實一樣的內容），
讓 C 對該檔案的這次 replay 變成 no-op。</p>
<hr>
<h2 id="注意事項">注意事項</h2>
<ul>
<li><strong>改寫已 push 的歷史需要 force push</strong>：用 <code>git push --force-with-lease</code> 比 <code>--force</code> 安全，
別人有新 commit 推上去時會被擋住，避免覆寫</li>
<li><strong>沒 push 的 commit 改起來無風險</strong>：怎麼操作都只影響本地</li>
<li><strong>改寫 main / master 是禁忌</strong>，這個技術只適用於 feature branch</li>
<li><strong>codegen 檔案</strong>：如果 <code>.freezed.dart</code> / <code>.g.dart</code> 等是被 gitignore 的，重組 source commit 後本地需要重跑 build_runner。如果 codegen 也在版控，建議連同 source 一起搬，否則 source 跟 codegen 對不齊</li>
<li><strong>Sequence editor 自動腳本</strong>搞不定的話，拿掉 <code>GIT_EDITOR=true</code>，讓 rebase 開你慣用的編輯器手動改 <code>pick</code> → <code>edit</code>，更直觀</li>
<li><strong>驗證樹一致性</strong>是這個工作流程的安全網。每次重組完一定要 <code>git diff backup HEAD</code> 跑一次</li>
</ul>
]]></content:encoded></item><item><title>Cost &amp; Checkpoint — 覆寫成本告知與 revert checkpoint</title><link>https://tarrragon.github.io/blog/skills/requirement-protocol/cost-and-checkpoint/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/requirement-protocol/cost-and-checkpoint/</guid><description>&lt;p>兩個情境的協議合併：&lt;strong>對抗多層的覆寫成本告知&lt;/strong> + &lt;strong>「先還原 / 先重來」類退出指令處理&lt;/strong>。共同主軸 = 把成本攤開、讓使用者參與決策、保留可逆性。&lt;/p>
&lt;p>適用：&lt;/p>
&lt;ul>
&lt;li>客製需求要對抗多層（vendor CSS、framework reconciliation、browser default、UA stylesheet）&lt;/li>
&lt;li>收到「先還原」「先重來」「換個方向」「我們重新開始」這類指令&lt;/li>
&lt;/ul>
&lt;p>不適用：純 greenfield 開發（沒有舊代碼要對抗、沒有探索成果要保留）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋成本告知模板、checkpoint 命名慣例、reset 前的確認協議。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>客製需求看似簡單但要打到 vendor / framework / UA 多層&lt;/td>
 &lt;td>在寫第一條規則前先報成本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將連寫 ≥ 3 條 &lt;code>!important&lt;/code> / 複雜 selector&lt;/td>
 &lt;td>停 — 寫成本報告、問使用者意願&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者說「先還原」「先重來」「思路不對、換」&lt;/td>
 &lt;td>確認還原目標 + 是否要 commit 當前進度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>探索了一個方向、最後沒採用&lt;/td>
 &lt;td>commit 一個 checkpoint 標「explored, not adopted」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將執行 &lt;code>git reset --hard&lt;/code> / &lt;code>git checkout .&lt;/code>&lt;/td>
 &lt;td>先確認哪些工作要保留、哪些要丟&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼成本要攤開為什麼-revert-要-checkpoint">為什麼成本要攤開、為什麼 revert 要 checkpoint&lt;/h2>
&lt;h3 id="成本攤開的價值">成本攤開的價值&lt;/h3>
&lt;p>當客製要對抗多層、執行者沉默地堆疊 &lt;code>!important&lt;/code> + specificity hack + polyfill — 結果使用者：&lt;/p>
&lt;ol>
&lt;li>看到「能用」的成果、以為成本低&lt;/li>
&lt;li>升級 vendor / 換 browser 後壞掉、驚訝於維護負擔&lt;/li>
&lt;li>不知道有沒有更便宜的替代方案（換 vendor、放棄該客製、改設計）&lt;/li>
&lt;/ol>
&lt;p>把成本攤開 = 使用者&lt;strong>在執行前&lt;/strong>就決定值不值、不在事後後悔。&lt;/p>
&lt;h3 id="revert-含-checkpoint-的價值">Revert 含 checkpoint 的價值&lt;/h3>
&lt;p>探索的成果即使沒採用、仍然是「為什麼不採用」的證據。直接清空：&lt;/p>
&lt;ol>
&lt;li>下次遇到類似需求、可能再走一遍同樣的死路&lt;/li>
&lt;li>失去 A 跟 B 兩條路的對比基礎&lt;/li>
&lt;li>部分技術選擇（命名、結構）可能仍有用、被連帶丟掉&lt;/li>
&lt;/ol>
&lt;p>Checkpoint 把「探索」與「採用」分開記錄、保留比較與恢復的可能。&lt;/p>
&lt;hr>
&lt;h2 id="成本告知協議">成本告知協議&lt;/h2>
&lt;h3 id="步驟-1列出對抗的層">步驟 1：列出對抗的層&lt;/h3>
&lt;p>寫第一條規則前、列出將打到哪幾層：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>對抗代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Browser UA 樣式&lt;/td>
 &lt;td>低 — UA 變動慢、跨瀏覽器差異是固定問題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vendor library&lt;/td>
 &lt;td>中 — 升級時可能變、需追蹤 vendor changelog&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework runtime&lt;/td>
 &lt;td>高 — reconciliation 會清掉、需在邊界外操作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自家舊 CSS&lt;/td>
 &lt;td>低 — 完全可控&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="步驟-2估規則數量與風險">步驟 2：估規則數量與風險&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">這個客製需要打到：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">- Vendor CSS（pagefind 主題色）：寫 3-4 條規則覆蓋預設色
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">- Framework reconciliation（drawer 內容會被重渲染）：把客製 UI 放邊界外
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">- 升級風險：pagefind 升級 minor 版本、選擇器改名 → 客製樣式失效
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">建議方案：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">A. 完整客製（如上）— 工時 1 hr、升級時要重檢
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">B. 只改 CSS variable（如果 vendor 提供）— 工時 5 min、升級安全
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">C. 放棄客製、用 vendor 預設 — 工時 0、視覺差異使用者要接受
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">推薦 B（如果 vendor 有提供 var）、否則 A。哪一個？&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="步驟-3使用者選擇後再開始">步驟 3：使用者選擇後再開始&lt;/h3>
&lt;p>不管選 A / B / C、選擇本身已經被攤開。使用者後續看到維護負擔、不會驚訝。&lt;/p></description><content:encoded><![CDATA[<p>兩個情境的協議合併：<strong>對抗多層的覆寫成本告知</strong> + <strong>「先還原 / 先重來」類退出指令處理</strong>。共同主軸 = 把成本攤開、讓使用者參與決策、保留可逆性。</p>
<p>適用：</p>
<ul>
<li>客製需求要對抗多層（vendor CSS、framework reconciliation、browser default、UA stylesheet）</li>
<li>收到「先還原」「先重來」「換個方向」「我們重新開始」這類指令</li>
</ul>
<p>不適用：純 greenfield 開發（沒有舊代碼要對抗、沒有探索成果要保留）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋成本告知模板、checkpoint 命名慣例、reset 前的確認協議。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>客製需求看似簡單但要打到 vendor / framework / UA 多層</td>
          <td>在寫第一條規則前先報成本</td>
      </tr>
      <tr>
          <td>即將連寫 ≥ 3 條 <code>!important</code> / 複雜 selector</td>
          <td>停 — 寫成本報告、問使用者意願</td>
      </tr>
      <tr>
          <td>使用者說「先還原」「先重來」「思路不對、換」</td>
          <td>確認還原目標 + 是否要 commit 當前進度</td>
      </tr>
      <tr>
          <td>探索了一個方向、最後沒採用</td>
          <td>commit 一個 checkpoint 標「explored, not adopted」</td>
      </tr>
      <tr>
          <td>即將執行 <code>git reset --hard</code> / <code>git checkout .</code></td>
          <td>先確認哪些工作要保留、哪些要丟</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼成本要攤開為什麼-revert-要-checkpoint">為什麼成本要攤開、為什麼 revert 要 checkpoint</h2>
<h3 id="成本攤開的價值">成本攤開的價值</h3>
<p>當客製要對抗多層、執行者沉默地堆疊 <code>!important</code> + specificity hack + polyfill — 結果使用者：</p>
<ol>
<li>看到「能用」的成果、以為成本低</li>
<li>升級 vendor / 換 browser 後壞掉、驚訝於維護負擔</li>
<li>不知道有沒有更便宜的替代方案（換 vendor、放棄該客製、改設計）</li>
</ol>
<p>把成本攤開 = 使用者<strong>在執行前</strong>就決定值不值、不在事後後悔。</p>
<h3 id="revert-含-checkpoint-的價值">Revert 含 checkpoint 的價值</h3>
<p>探索的成果即使沒採用、仍然是「為什麼不採用」的證據。直接清空：</p>
<ol>
<li>下次遇到類似需求、可能再走一遍同樣的死路</li>
<li>失去 A 跟 B 兩條路的對比基礎</li>
<li>部分技術選擇（命名、結構）可能仍有用、被連帶丟掉</li>
</ol>
<p>Checkpoint 把「探索」與「採用」分開記錄、保留比較與恢復的可能。</p>
<hr>
<h2 id="成本告知協議">成本告知協議</h2>
<h3 id="步驟-1列出對抗的層">步驟 1：列出對抗的層</h3>
<p>寫第一條規則前、列出將打到哪幾層：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>對抗代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Browser UA 樣式</td>
          <td>低 — UA 變動慢、跨瀏覽器差異是固定問題</td>
      </tr>
      <tr>
          <td>Vendor library</td>
          <td>中 — 升級時可能變、需追蹤 vendor changelog</td>
      </tr>
      <tr>
          <td>Framework runtime</td>
          <td>高 — reconciliation 會清掉、需在邊界外操作</td>
      </tr>
      <tr>
          <td>自家舊 CSS</td>
          <td>低 — 完全可控</td>
      </tr>
  </tbody>
</table>
<h3 id="步驟-2估規則數量與風險">步驟 2：估規則數量與風險</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">這個客製需要打到：
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">- Vendor CSS（pagefind 主題色）：寫 3-4 條規則覆蓋預設色
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">- Framework reconciliation（drawer 內容會被重渲染）：把客製 UI 放邊界外
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">- 升級風險：pagefind 升級 minor 版本、選擇器改名 → 客製樣式失效
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">建議方案：
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">A. 完整客製（如上）— 工時 1 hr、升級時要重檢
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">B. 只改 CSS variable（如果 vendor 提供）— 工時 5 min、升級安全
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">C. 放棄客製、用 vendor 預設 — 工時 0、視覺差異使用者要接受
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">推薦 B（如果 vendor 有提供 var）、否則 A。哪一個？</span></span></code></pre></div><h3 id="步驟-3使用者選擇後再開始">步驟 3：使用者選擇後再開始</h3>
<p>不管選 A / B / C、選擇本身已經被攤開。使用者後續看到維護負擔、不會驚訝。</p>
<hr>
<h2 id="revert--checkpoint-協議">Revert / Checkpoint 協議</h2>
<h3 id="步驟-1確認還原目標">步驟 1：確認還原目標</h3>
<p>使用者說「先還原」時、回問：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">「還原」是指：
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">(a) 丟掉所有未 commit 的修改、回到 HEAD
</span></span><span class="line"><span class="ln">4</span><span class="cl">(b) 回到某個特定 commit（哪一個？）
</span></span><span class="line"><span class="ln">5</span><span class="cl">(c) 部分還原（哪些檔案 / 哪些功能）
</span></span><span class="line"><span class="ln">6</span><span class="cl">(d) 換思路、但保留結構（命名、檔案組織保留、實作換掉）
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl">我建議先做 commit 把當前進度保存、再 reset — 您是哪一種？</span></span></code></pre></div><h3 id="步驟-2commit-當前進度當-checkpoint">步驟 2：commit 當前進度當 checkpoint</h3>
<p>不管是哪種還原、先 commit：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git add -A
</span></span><span class="line"><span class="ln">2</span><span class="cl">git commit -m <span class="s2">&#34;checkpoint: explored [方向 X], not adopted
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s2">- 嘗試了 [做法 A]、結果 [現象]
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s2">- 假設 [Z] 驗證後不成立
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s2">- 保留供未來對比、不採用為最終解
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s2">&#34;</span></span></span></code></pre></div><p>Checkpoint commit 的特徵：</p>
<ul>
<li>主題明確含「checkpoint」「explored」「not adopted」字樣</li>
<li>body 寫「為什麼不採用」、不只寫「做了什麼」</li>
<li>在後續 main branch 上不會被 merge 進去（用 branch 隔離或日後 rebase 丟）</li>
</ul>
<h3 id="步驟-3執行-reset">步驟 3：執行 reset</h3>
<p>確認 checkpoint commit 完成後、執行使用者要的還原：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 視類型而定</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git reset --hard &lt;target&gt;      <span class="c1"># 完全還原</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">git checkout &lt;commit&gt; -- &lt;file&gt; <span class="c1"># 部分還原</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">git revert &lt;commit&gt;             <span class="c1"># 保留歷史的還原</span></span></span></code></pre></div><p>危險動作（<code>git reset --hard</code>、<code>git checkout .</code>、<code>git clean -fd</code>）執行前再次跟使用者確認 — 即使有 checkpoint commit、也要避免把 checkpoint 自己 reset 掉。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1覆寫成本">範例 1：覆寫成本</h3>
<blockquote>
<p>使用者：「pagefind 搜尋框的顏色改成我們的品牌色」</p></blockquote>
<p><strong>錯</strong>（直接做、沒攤成本）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__form</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">brand</span><span class="p">)</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__search-input</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">brand</span><span class="p">)</span> <span class="cp">!important</span><span class="p">;</span> <span class="k">color</span><span class="p">:</span> <span class="kc">white</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__button</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">brand</span><span class="o">-</span><span class="n">dark</span><span class="p">)</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c">/* ... 8 條 !important 後、終於看起來對 */</span></span></span></code></pre></div><p>兩週後 pagefind 升級、class 名稱改了一個 — 全壞。使用者驚訝。</p>
<p><strong>對</strong>（先報成本）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">顏色客製有兩條路：
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">A. Pagefind 提供的 CSS variables（看了 docs：--pagefind-ui-primary、--pagefind-ui-text 等）
</span></span><span class="line"><span class="ln">4</span><span class="cl">   → 改 4 個 var、升級安全、5 min
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">B. 直接覆寫 class 規則（要 8 條 !important）
</span></span><span class="line"><span class="ln">7</span><span class="cl">   → 升級可能壞、需要追蹤 vendor changelog、1 hr
</span></span><span class="line"><span class="ln">8</span><span class="cl">
</span></span><span class="line"><span class="ln">9</span><span class="cl">推薦 A、除非您要的視覺超出 var 能控制的範圍。哪個？</span></span></code></pre></div><h3 id="範例-2先還原處理">範例 2：「先還原」處理</h3>
<blockquote>
<p>使用者：「等等先還原、這個方向不對」</p></blockquote>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git checkout .  <span class="c1"># 直接清空、丟掉 30 分鐘探索成果</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 之後想對比「為什麼方向 A 不行」時、什麼都沒留下</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">我把當前進度先 commit 成 checkpoint、再還原 — 還原的目標是：
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl">(a) 回到開始這個方向之前的 commit（HEAD~3）
</span></span><span class="line"><span class="ln">4</span><span class="cl">(b) 回到 main 最新
</span></span><span class="line"><span class="ln">5</span><span class="cl">(c) 換個 branch、保留這個 branch 供以後參考
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl">通常 (c) 最安全、(a) 最常見。您要哪個？</span></span></code></pre></div><p>確認後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git add -A
</span></span><span class="line"><span class="ln">2</span><span class="cl">git commit -m <span class="s2">&#34;checkpoint: explored grid-row layout, not adopted (drawer is form&#39;s child, grid invalid)&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">git reset --hard HEAD~4  <span class="c1"># 或使用者指定的 target</span></span></span></code></pre></div><hr>
<h2 id="checkpoint-commit-的命名慣例">Checkpoint commit 的命名慣例</h2>
<table>
  <thead>
      <tr>
          <th>前綴</th>
          <th>用途</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>checkpoint:</code></td>
          <td>探索成果、未採用、保留參考</td>
          <td><code>checkpoint: explored A approach, not adopted</code></td>
      </tr>
      <tr>
          <td><code>wip:</code></td>
          <td>進行中、之後會 rebase / squash</td>
          <td><code>wip: trying scope toggle with regex</code></td>
      </tr>
      <tr>
          <td><code>spike:</code></td>
          <td>純探索、無意採用、純驗證可行性</td>
          <td><code>spike: pagefind perf with 5000 docs</code></td>
      </tr>
  </tbody>
</table>
<p><code>checkpoint:</code> 是本文件主推 — 比 <code>wip:</code> 多了「不採用」的明確標記、未來 grep <code>git log --grep=checkpoint</code> 能快速找到「曾經試過但放棄的方向」。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>收到客製需求或 revert 指令時：</p>
<ul>
<li><input disabled="" type="checkbox"> 寫第一條覆寫規則前、有沒有列出「對抗哪幾層、規則數量、升級風險」？</li>
<li><input disabled="" type="checkbox"> 有沒有給使用者 ≥ 2 個選項（含「不做」或「降級客製」）？</li>
<li><input disabled="" type="checkbox"> revert 前有沒有確認還原目標的精確意圖？</li>
<li><input disabled="" type="checkbox"> revert 前有沒有 commit 一個 checkpoint？</li>
<li><input disabled="" type="checkbox"> checkpoint 的 commit message 有沒有寫「為什麼不採用」、不只寫「做了什麼」？</li>
</ul>
<p>成本沒攤、checkpoint 沒 commit → 退回去補。</p>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/override-depth-cost-report/" data-link-title="覆寫深度的成本告知" data-link-desc="客製可能對抗 UA &#43; 跨瀏覽器 &#43; framework 三層時、先報需要寫多少規則 / 哪幾條 / 殘留風險、讓使用者判斷值不值再開工。本文展開覆寫深度的事前告知 protocol。">override-depth-cost-report</a> — 覆寫深度的成本告知</li>
<li><a href="/blog/report/revert-instruction-handling/" data-link-title="「先還原」「先重來」類退出指令的處理" data-link-desc="聽到「還原 / 重來」時、先問「還原到哪個 commit？要不要先 commit 一個 checkpoint 再動、方便日後比對？」本文展開退出指令的安全處理 protocol。">revert-instruction-handling</a> — 「先還原」「先重來」類退出指令的處理</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Git：修復後面的 commit 意外覆蓋前面 commit 的變更</title><link>https://tarrragon.github.io/blog/work-log/git%E4%BF%AE%E5%BE%A9%E5%BE%8C%E9%9D%A2%E7%9A%84-commit-%E6%84%8F%E5%A4%96%E8%A6%86%E8%93%8B%E5%89%8D%E9%9D%A2-commit-%E7%9A%84%E8%AE%8A%E6%9B%B4/</link><pubDate>Tue, 24 Feb 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/git%E4%BF%AE%E5%BE%A9%E5%BE%8C%E9%9D%A2%E7%9A%84-commit-%E6%84%8F%E5%A4%96%E8%A6%86%E8%93%8B%E5%89%8D%E9%9D%A2-commit-%E7%9A%84%E8%AE%8A%E6%9B%B4/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>使用 &lt;code>git commit --fixup&lt;/code> + &lt;code>git rebase --autosquash&lt;/code> 修改歷史 commit 後，
修改內容被後續的 commit 覆蓋，導致變更未生效。&lt;/p>
&lt;h3 id="範例">範例&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">gitGraph
 commit id: &amp;#34;A (refactor)&amp;#34; type: HIGHLIGHT
 commit id: &amp;#34;B (fix)&amp;#34;
 commit id: &amp;#34;C (fix)&amp;#34;
 commit id: &amp;#34;D (feat)&amp;#34; type: REVERSE&lt;/code>&lt;/pre>&lt;blockquote>
&lt;p>HIGHLIGHT = 要修改的目標 commit（A）
REVERSE = 意外包含同一檔案變更的 commit（D）&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>&lt;strong>目標&lt;/strong>：透過 fixup 修改 commit A，移除 &lt;code>table_service.dart&lt;/code> 中的 try-catch&lt;/li>
&lt;li>&lt;strong>問題&lt;/strong>：commit D 在開發時意外 stage 了 &lt;code>table_service.dart&lt;/code> 的變更，導致 rebase 後 commit D 重新套用了舊的內容，覆蓋了 commit A 的修改&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="解決方案從-commit-中移除不該包含的檔案">解決方案：從 commit 中移除不該包含的檔案&lt;/h2>
&lt;h3 id="前置確認">前置確認&lt;/h3>
&lt;p>先確認哪些 commit 修改了目標檔案：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git log --oneline -- lib/data/services/table/table_service.dart&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸出類似：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">de60cc9 feat: 追加多語系 ← 不該包含此檔案
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">b55e504 refactor: 各 Service 實作 ← 預期的修改&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>確認 commit D 確實不該包含該檔案後，進行修復。&lt;/p>
&lt;h3 id="步驟">步驟&lt;/h3>
&lt;h4 id="1-暫存目前的工作變更如果有的話">1. 暫存目前的工作變更（如果有的話）&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git stash&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="2-啟動-interactive-rebase將目標-commit-標記為-edit">2. 啟動 interactive rebase，將目標 commit 標記為 edit&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">GIT_SEQUENCE_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sed -i &amp;#39;&amp;#39; &amp;#39;1s/^pick/edit/&amp;#39;&amp;#34;&lt;/span> git rebase -i &amp;lt;目標commit&amp;gt;~1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>說明&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;code>&amp;lt;目標commit&amp;gt;~1&lt;/code> 表示從目標 commit 的&lt;strong>前一個&lt;/strong> commit 開始 rebase&lt;/li>
&lt;li>&lt;code>GIT_SEQUENCE_EDITOR=&amp;quot;sed -i '' '1s/^pick/edit/'&amp;quot;&lt;/code> 自動將第一行（目標 commit）從 &lt;code>pick&lt;/code> 改為 &lt;code>edit&lt;/code>，避免手動編輯&lt;/li>
&lt;li>macOS 的 &lt;code>sed -i&lt;/code> 需要 &lt;code>''&lt;/code> 參數，Linux 則不需要&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>以本例來說：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">GIT_SEQUENCE_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sed -i &amp;#39;&amp;#39; &amp;#39;1s/^pick/edit/&amp;#39;&amp;#34;&lt;/span> git rebase -i de60cc9~1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>執行後 Git 會停在 &lt;code>de60cc9&lt;/code>，等待你修改。&lt;/p>
&lt;p>此時的 commit 狀態：&lt;/p>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">gitGraph
 commit id: &amp;#34;A (refactor)&amp;#34;
 commit id: &amp;#34;B (fix)&amp;#34;
 commit id: &amp;#34;C (fix)&amp;#34;
 commit id: &amp;#34;D (feat)&amp;#34; type: REVERSE tag: &amp;#34;HEAD (edit)&amp;#34;&lt;/code>&lt;/pre>&lt;h4 id="3-將目標檔案還原到前一個-commit的狀態">3. 將目標檔案還原到「前一個 commit」的狀態&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git checkout HEAD~1 -- &amp;lt;檔案路徑&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>以本例來說：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git checkout HEAD~1 -- lib/data/services/table/table_service.dart&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>說明&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;code>HEAD~1&lt;/code> 是目標 commit 的前一個 commit&lt;/li>
&lt;li>這會把檔案還原到 commit D &lt;strong>之前&lt;/strong>的狀態，等於「撤銷 commit D 對這個檔案的修改」&lt;/li>
&lt;li>還原後檔案會自動被加入暫存區（staged）&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>此時可以用 &lt;code>git status&lt;/code> 確認狀態，應該會看到：&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>使用 <code>git commit --fixup</code> + <code>git rebase --autosquash</code> 修改歷史 commit 後，
修改內容被後續的 commit 覆蓋，導致變更未生效。</p>
<h3 id="範例">範例</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (refactor)&#34; type: HIGHLIGHT
   commit id: &#34;B (fix)&#34;
   commit id: &#34;C (fix)&#34;
   commit id: &#34;D (feat)&#34; type: REVERSE</code></pre><blockquote>
<p>HIGHLIGHT = 要修改的目標 commit（A）
REVERSE = 意外包含同一檔案變更的 commit（D）</p></blockquote>
<ul>
<li><strong>目標</strong>：透過 fixup 修改 commit A，移除 <code>table_service.dart</code> 中的 try-catch</li>
<li><strong>問題</strong>：commit D 在開發時意外 stage 了 <code>table_service.dart</code> 的變更，導致 rebase 後 commit D 重新套用了舊的內容，覆蓋了 commit A 的修改</li>
</ul>
<hr>
<h2 id="解決方案從-commit-中移除不該包含的檔案">解決方案：從 commit 中移除不該包含的檔案</h2>
<h3 id="前置確認">前置確認</h3>
<p>先確認哪些 commit 修改了目標檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git log --oneline -- lib/data/services/table/table_service.dart</span></span></code></pre></div><p>輸出類似：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">de60cc9 feat: 追加多語系           ← 不該包含此檔案
</span></span><span class="line"><span class="ln">2</span><span class="cl">b55e504 refactor: 各 Service 實作   ← 預期的修改</span></span></code></pre></div><p>確認 commit D 確實不該包含該檔案後，進行修復。</p>
<h3 id="步驟">步驟</h3>
<h4 id="1-暫存目前的工作變更如果有的話">1. 暫存目前的工作變更（如果有的話）</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git stash</span></span></code></pre></div><h4 id="2-啟動-interactive-rebase將目標-commit-標記為-edit">2. 啟動 interactive rebase，將目標 commit 標記為 edit</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">&#34;sed -i &#39;&#39; &#39;1s/^pick/edit/&#39;&#34;</span> git rebase -i &lt;目標commit&gt;~1</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>&lt;目標commit&gt;~1</code> 表示從目標 commit 的<strong>前一個</strong> commit 開始 rebase</li>
<li><code>GIT_SEQUENCE_EDITOR=&quot;sed -i '' '1s/^pick/edit/'&quot;</code> 自動將第一行（目標 commit）從 <code>pick</code> 改為 <code>edit</code>，避免手動編輯</li>
<li>macOS 的 <code>sed -i</code> 需要 <code>''</code> 參數，Linux 則不需要</li>
</ul></blockquote>
<p>以本例來說：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">&#34;sed -i &#39;&#39; &#39;1s/^pick/edit/&#39;&#34;</span> git rebase -i de60cc9~1</span></span></code></pre></div><p>執行後 Git 會停在 <code>de60cc9</code>，等待你修改。</p>
<p>此時的 commit 狀態：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (refactor)&#34;
   commit id: &#34;B (fix)&#34;
   commit id: &#34;C (fix)&#34;
   commit id: &#34;D (feat)&#34; type: REVERSE tag: &#34;HEAD (edit)&#34;</code></pre><h4 id="3-將目標檔案還原到前一個-commit的狀態">3. 將目標檔案還原到「前一個 commit」的狀態</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git checkout HEAD~1 -- &lt;檔案路徑&gt;</span></span></code></pre></div><p>以本例來說：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git checkout HEAD~1 -- lib/data/services/table/table_service.dart</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>HEAD~1</code> 是目標 commit 的前一個 commit</li>
<li>這會把檔案還原到 commit D <strong>之前</strong>的狀態，等於「撤銷 commit D 對這個檔案的修改」</li>
<li>還原後檔案會自動被加入暫存區（staged）</li>
</ul></blockquote>
<p>此時可以用 <code>git status</code> 確認狀態，應該會看到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">interactive rebase in progress; onto &lt;hash&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ...
</span></span><span class="line"><span class="ln">3</span><span class="cl">Changes to be committed:
</span></span><span class="line"><span class="ln">4</span><span class="cl">  (use &#34;git restore --staged &lt;file&gt;...&#34; to unstage)
</span></span><span class="line"><span class="ln">5</span><span class="cl">        modified:   lib/data/services/table/table_service.dart</span></span></code></pre></div><h4 id="4-修改-commit-並繼續-rebase">4. 修改 commit 並繼續 rebase</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git commit --amend --no-edit <span class="o">&amp;&amp;</span> git rebase --continue</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>--amend</code> 修改當前 commit（即 de60cc9）</li>
<li><code>--no-edit</code> 保留原本的 commit message 不變</li>
<li><code>git rebase --continue</code> 繼續處理後續的 commit</li>
</ul></blockquote>
<p>完成後的 commit 狀態：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (refactor)&#34; type: HIGHLIGHT
   commit id: &#34;B (fix)&#34;
   commit id: &#34;C (fix)&#34;
   commit id: &#34;D&#39; (feat)&#34; tag: &#34;HEAD&#34;</code></pre><blockquote>
<p>D 變為 D&rsquo;（新的 hash），不再包含 <code>table_service.dart</code> 的變更。</p></blockquote>
<h4 id="5-恢復暫存的工作變更如果有的話">5. 恢復暫存的工作變更（如果有的話）</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git stash pop</span></span></code></pre></div><h3 id="驗證">驗證</h3>
<p>確認目標 commit 不再包含該檔案的修改：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git show &lt;新的commit hash&gt; --stat</span></span></code></pre></div><p>輸出的修改清單中不應出現 <code>table_service.dart</code>。</p>
<hr>
<h2 id="完整指令摘要">完整指令摘要</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 0. 暫存工作區</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git stash
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 1. 進入 interactive rebase，自動標記目標 commit 為 edit</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">&#34;sed -i &#39;&#39; &#39;1s/^pick/edit/&#39;&#34;</span> git rebase -i &lt;目標commit&gt;~1
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 2. 還原該檔案到 commit 之前的狀態</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">git checkout HEAD~1 -- &lt;檔案路徑&gt;
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 3. 修改 commit 並繼續 rebase</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">git commit --amend --no-edit <span class="o">&amp;&amp;</span> git rebase --continue
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 4. 恢復工作區</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">git stash pop
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"># 5. 驗證</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">git show HEAD --stat</span></span></code></pre></div><hr>
<h2 id="衍伸搭配-fixup-的完整工作流程">衍伸：搭配 fixup 的完整工作流程</h2>
<p>當你需要<strong>修改歷史 commit A 的內容</strong>，但<strong>後面的 commit D 又意外包含同一個檔案的修改</strong>時：</p>
<h3 id="正確操作順序">正確操作順序</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
   Step1[&#34;① 清理 commit D\n移除不該包含的檔案&#34;] --&gt; Step2[&#34;② 修改 commit A\nfixup + autosquash&#34;]

   style Step1 fill:#e53e3e,color:#fff,stroke:#c53030
   style Step2 fill:#38a169,color:#fff,stroke:#2f855a</code></pre><blockquote>
<p>如果先做 fixup 再處理 commit D，fixup 的修改會被 commit D 覆蓋。
所以<strong>一定要先清理後面的 commit，再修改前面的 commit</strong>。</p></blockquote>
<h3 id="fixup--autosquash-參考指令">fixup + autosquash 參考指令</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 建立 fixup commit（指向要修改的目標 commit）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git add &lt;修改的檔案&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">git commit --fixup<span class="o">=</span>&lt;目標commit hash&gt;
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 執行 autosquash rebase（自動合併 fixup commit）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="nb">true</span> git rebase -i --autosquash &lt;目標commit&gt;~1</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>--fixup=&lt;hash&gt;</code> 會建立一個以 <code>fixup!</code> 為前綴的 commit</li>
<li><code>--autosquash</code> 會自動將 fixup commit 排到目標 commit 後面並標記為 fixup</li>
<li><code>GIT_SEQUENCE_EDITOR=true</code> 跳過編輯器，直接執行（因為 autosquash 已經排好了）</li>
</ul></blockquote>
<hr>
<h2 id="注意事項">注意事項</h2>
<ul>
<li>這些操作會<strong>改寫 git 歷史</strong>，只適用於尚未 push 到遠端的 commit（或你有權 force push 的分支）</li>
<li>操作前建議用 <code>git log --oneline -10</code> 確認目前的 commit 順序</li>
<li>如果 rebase 過程中遇到衝突，用 <code>git status</code> 查看衝突檔案，手動解決後執行 <code>git add</code> + <code>git rebase --continue</code></li>
<li>如果想放棄 rebase，可以用 <code>git rebase --abort</code> 回到操作前的狀態</li>
</ul>
]]></content:encoded></item><item><title>Git Filter-Repo 使用說明</title><link>https://tarrragon.github.io/blog/work-log/git-filter-repo-%E4%BD%BF%E7%94%A8%E8%AA%AA%E6%98%8E/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/git-filter-repo-%E4%BD%BF%E7%94%A8%E8%AA%AA%E6%98%8E/</guid><description>&lt;p>&lt;code>git filter-repo&lt;/code> 是一個強大的工具，用於重寫 Git 歷史記錄。它比 &lt;code>git filter-branch&lt;/code> 更快、更安全，是官方推薦的替代方案。&lt;/p>
&lt;h2 id="目錄">目錄&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#%e5%ae%89%e8%a3%9d">安裝&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%9f%ba%e6%9c%ac%e6%a6%82%e5%bf%b5">基本概念&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%b8%b8%e7%94%a8%e6%93%8d%e4%bd%9c">常用操作&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#%e7%a7%bb%e9%99%a4%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">移除檔案或資料夾&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%8f%aa%e4%bf%9d%e7%95%99%e7%89%b9%e5%ae%9a%e8%b7%af%e5%be%91">只保留特定路徑&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e9%87%8d%e6%96%b0%e5%91%bd%e5%90%8d%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">重新命名檔案或資料夾&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e4%bf%ae%e6%94%b9-commit-%e8%a8%8a%e6%81%af">修改 commit 訊息&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e4%bf%ae%e6%94%b9%e4%bd%9c%e8%80%85%e8%b3%87%e8%a8%8a">修改作者資訊&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#%e9%80%b2%e9%9a%8e%e6%93%8d%e4%bd%9c">進階操作&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e6%b3%a8%e6%84%8f%e4%ba%8b%e9%a0%85">注意事項&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%b8%b8%e8%a6%8b%e5%95%8f%e9%a1%8c">常見問題&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="安裝">安裝&lt;/h2>
&lt;h3 id="macos">macOS&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 使用 Homebrew&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">brew install git-filter-repo
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 或使用 pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip3 install git-filter-repo&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="linux">Linux&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sudo apt install git-filter-repo
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 或使用 pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip3 install git-filter-repo&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="windows">Windows&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 使用 pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pip install git-filter-repo&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="基本概念">基本概念&lt;/h2>
&lt;h3 id="什麼時候使用-git-filter-repo">什麼時候使用 git filter-repo？&lt;/h3>
&lt;ul>
&lt;li>從歷史記錄中移除敏感資訊（密碼、API 金鑰等）&lt;/li>
&lt;li>移除不小心 commit 的大型檔案&lt;/li>
&lt;li>將子目錄拆分成獨立的 repository&lt;/li>
&lt;li>合併多個 repository&lt;/li>
&lt;li>批量修改 commit 作者資訊&lt;/li>
&lt;/ul>
&lt;h3 id="重要提醒">重要提醒&lt;/h3>
&lt;p>注意：&lt;code>git filter-repo&lt;/code> 會&lt;strong>重寫 Git 歷史&lt;/strong>，這意味著：&lt;/p>
&lt;ol>
&lt;li>所有 commit hash 都會改變&lt;/li>
&lt;li>需要 force push 到遠端&lt;/li>
&lt;li>其他協作者需要重新 clone 或 reset&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="常用操作">常用操作&lt;/h2>
&lt;h3 id="移除檔案或資料夾">移除檔案或資料夾&lt;/h3>
&lt;h4 id="移除單一檔案">移除單一檔案&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path path/to/file.txt --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="移除資料夾">移除資料夾&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path path/to/folder --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="移除多個路徑">移除多個路徑&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path .env --path secrets/ --path config/credentials.json --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="使用-glob-模式移除">使用 glob 模式移除&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 移除所有 .log 檔案&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path-glob &lt;span class="s1">&amp;#39;*.log&amp;#39;&lt;/span> --force
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 移除所有 node_modules 資料夾&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path-glob &lt;span class="s1">&amp;#39;**/node_modules/*&amp;#39;&lt;/span> --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="使用正規表達式移除">使用正規表達式移除&lt;/h4>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 移除所有 .env 開頭的檔案&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path-regex &lt;span class="s1">&amp;#39;^\.env.*&amp;#39;&lt;/span> --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="只保留特定路徑">只保留特定路徑&lt;/h3>
&lt;p>將 repository 縮減為只包含特定資料夾（適用於拆分專案）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 只保留 src 資料夾&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --path src --force
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 保留多個路徑&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git filter-repo --path src --path docs --path README.md --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="重新命名檔案或資料夾">重新命名檔案或資料夾&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 將 old-name 重新命名為 new-name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --path-rename old-name:new-name --force
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 將資料夾移動到子目錄&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git filter-repo --path-rename src:app/src --force
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 將所有檔案移到子目錄&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">git filter-repo --to-subdirectory-filter my-subdir --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="修改-commit-訊息">修改 commit 訊息&lt;/h3>
&lt;p>建立一個 Python 腳本 &lt;code>message-callback.py&lt;/code>：&lt;/p></description><content:encoded><![CDATA[<p><code>git filter-repo</code> 是一個強大的工具，用於重寫 Git 歷史記錄。它比 <code>git filter-branch</code> 更快、更安全，是官方推薦的替代方案。</p>
<h2 id="目錄">目錄</h2>
<ul>
<li><a href="#%e5%ae%89%e8%a3%9d">安裝</a></li>
<li><a href="#%e5%9f%ba%e6%9c%ac%e6%a6%82%e5%bf%b5">基本概念</a></li>
<li><a href="#%e5%b8%b8%e7%94%a8%e6%93%8d%e4%bd%9c">常用操作</a>
<ul>
<li><a href="#%e7%a7%bb%e9%99%a4%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">移除檔案或資料夾</a></li>
<li><a href="#%e5%8f%aa%e4%bf%9d%e7%95%99%e7%89%b9%e5%ae%9a%e8%b7%af%e5%be%91">只保留特定路徑</a></li>
<li><a href="#%e9%87%8d%e6%96%b0%e5%91%bd%e5%90%8d%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">重新命名檔案或資料夾</a></li>
<li><a href="#%e4%bf%ae%e6%94%b9-commit-%e8%a8%8a%e6%81%af">修改 commit 訊息</a></li>
<li><a href="#%e4%bf%ae%e6%94%b9%e4%bd%9c%e8%80%85%e8%b3%87%e8%a8%8a">修改作者資訊</a></li>
</ul>
</li>
<li><a href="#%e9%80%b2%e9%9a%8e%e6%93%8d%e4%bd%9c">進階操作</a></li>
<li><a href="#%e6%b3%a8%e6%84%8f%e4%ba%8b%e9%a0%85">注意事項</a></li>
<li><a href="#%e5%b8%b8%e8%a6%8b%e5%95%8f%e9%a1%8c">常見問題</a></li>
</ul>
<hr>
<h2 id="安裝">安裝</h2>
<h3 id="macos">macOS</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 使用 Homebrew</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">brew install git-filter-repo
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 或使用 pip</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pip3 install git-filter-repo</span></span></code></pre></div><h3 id="linux">Linux</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo apt install git-filter-repo
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 或使用 pip</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pip3 install git-filter-repo</span></span></code></pre></div><h3 id="windows">Windows</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 使用 pip</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pip install git-filter-repo</span></span></code></pre></div><hr>
<h2 id="基本概念">基本概念</h2>
<h3 id="什麼時候使用-git-filter-repo">什麼時候使用 git filter-repo？</h3>
<ul>
<li>從歷史記錄中移除敏感資訊（密碼、API 金鑰等）</li>
<li>移除不小心 commit 的大型檔案</li>
<li>將子目錄拆分成獨立的 repository</li>
<li>合併多個 repository</li>
<li>批量修改 commit 作者資訊</li>
</ul>
<h3 id="重要提醒">重要提醒</h3>
<p>注意：<code>git filter-repo</code> 會<strong>重寫 Git 歷史</strong>，這意味著：</p>
<ol>
<li>所有 commit hash 都會改變</li>
<li>需要 force push 到遠端</li>
<li>其他協作者需要重新 clone 或 reset</li>
</ol>
<hr>
<h2 id="常用操作">常用操作</h2>
<h3 id="移除檔案或資料夾">移除檔案或資料夾</h3>
<h4 id="移除單一檔案">移除單一檔案</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --invert-paths --path path/to/file.txt --force</span></span></code></pre></div><h4 id="移除資料夾">移除資料夾</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --invert-paths --path path/to/folder --force</span></span></code></pre></div><h4 id="移除多個路徑">移除多個路徑</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --invert-paths --path .env --path secrets/ --path config/credentials.json --force</span></span></code></pre></div><h4 id="使用-glob-模式移除">使用 glob 模式移除</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 移除所有 .log 檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --invert-paths --path-glob <span class="s1">&#39;*.log&#39;</span> --force
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 移除所有 node_modules 資料夾</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git filter-repo --invert-paths --path-glob <span class="s1">&#39;**/node_modules/*&#39;</span> --force</span></span></code></pre></div><h4 id="使用正規表達式移除">使用正規表達式移除</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 移除所有 .env 開頭的檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --invert-paths --path-regex <span class="s1">&#39;^\.env.*&#39;</span> --force</span></span></code></pre></div><h3 id="只保留特定路徑">只保留特定路徑</h3>
<p>將 repository 縮減為只包含特定資料夾（適用於拆分專案）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 只保留 src 資料夾</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --path src --force
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 保留多個路徑</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git filter-repo --path src --path docs --path README.md --force</span></span></code></pre></div><h3 id="重新命名檔案或資料夾">重新命名檔案或資料夾</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 將 old-name 重新命名為 new-name</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --path-rename old-name:new-name --force
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 將資料夾移動到子目錄</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git filter-repo --path-rename src:app/src --force
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 將所有檔案移到子目錄</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">git filter-repo --to-subdirectory-filter my-subdir --force</span></span></code></pre></div><h3 id="修改-commit-訊息">修改 commit 訊息</h3>
<p>建立一個 Python 腳本 <code>message-callback.py</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># message-callback.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">def</span> <span class="nf">message_callback</span><span class="p">(</span><span class="n">message</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="c1"># 將 &#34;bug&#34; 替換為 &#34;fix&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">b</span><span class="s1">&#39;bug&#39;</span><span class="p">,</span> <span class="sa">b</span><span class="s1">&#39;fix&#39;</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span></span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --message-callback <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">    return message.replace(b&#34;bug&#34;, b&#34;fix&#34;)
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">&#39;</span> --force</span></span></code></pre></div><p>或使用外部檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --message-callback <span class="s2">&#34;</span><span class="k">$(</span>cat message-callback.py<span class="k">)</span><span class="s2">&#34;</span> --force</span></span></code></pre></div><h3 id="修改作者資訊">修改作者資訊</h3>
<h4 id="使用-mailmap-檔案">使用 mailmap 檔案</h4>
<p>建立 <code>.mailmap</code> 檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">New Name &lt;new@email.com&gt; Old Name &lt;old@email.com&gt;</span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --mailmap .mailmap --force</span></span></code></pre></div><h4 id="使用-callback-函數">使用 callback 函數</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --name-callback <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">    return name.replace(b&#34;OldName&#34;, b&#34;NewName&#34;)
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">&#39;</span> --email-callback <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    return email.replace(b&#34;old@email.com&#34;, b&#34;new@email.com&#34;)
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">&#39;</span> --force</span></span></code></pre></div><hr>
<h2 id="進階操作">進階操作</h2>
<h3 id="移除大型檔案">移除大型檔案</h3>
<p>找出大型檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --analyze
</span></span><span class="line"><span class="ln">2</span><span class="cl">cat .git/filter-repo/analysis/blob-shas-and-paths.txt <span class="p">|</span> head -20</span></span></code></pre></div><p>移除超過特定大小的檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --strip-blobs-bigger-than 10M --force</span></span></code></pre></div><h3 id="替換敏感內容">替換敏感內容</h3>
<p>建立替換規則檔案 <code>replacements.txt</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">regex</span><span class="o">:</span><span class="nx">password</span><span class="o">=</span><span class="p">.</span><span class="o">*=</span><span class="p">=&gt;</span><span class="nx">password</span><span class="o">=</span><span class="nx">REDACTED</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">literal</span><span class="o">:</span><span class="nx">my</span><span class="o">-</span><span class="nx">secret</span><span class="o">-</span><span class="nx">api</span><span class="o">-</span><span class="nx">key</span><span class="o">==&gt;</span><span class="nx">API_KEY_REMOVED</span></span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --replace-text replacements.txt --force</span></span></code></pre></div><h3 id="只處理部分歷史">只處理部分歷史</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 只處理最近的 commit</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --refs HEAD~10..HEAD --path sensitive-file --invert-paths --force
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 處理特定分支</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git filter-repo --refs main --path old-folder --invert-paths --force</span></span></code></pre></div><h3 id="保留備份">保留備份</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在操作前建立備份分支</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git branch backup-before-filter
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 或 clone 一份完整備份</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git clone --mirror original-repo backup-repo</span></span></code></pre></div><hr>
<h2 id="注意事項">注意事項</h2>
<h3 id="操作前檢查清單">操作前檢查清單</h3>
<ul>
<li><input disabled="" type="checkbox"> 確保工作目錄是乾淨的（<code>git status</code> 無未 commit 的變更）</li>
<li><input disabled="" type="checkbox"> 建立備份（branch 或完整 clone）</li>
<li><input disabled="" type="checkbox"> 確認沒有其他人正在使用這個 repository</li>
<li><input disabled="" type="checkbox"> 了解 force push 的影響</li>
</ul>
<h3 id="remote-會被移除">Remote 會被移除</h3>
<p><code>git filter-repo</code> 執行後會<strong>自動移除 <code>origin</code> remote</strong>。執行時你會看到以下提示：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">NOTICE: Removing <span class="s1">&#39;origin&#39;</span> remote<span class="p">;</span> see <span class="s1">&#39;Why is my origin removed?&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">        in the manual <span class="k">if</span> you want to push back there.</span></span></code></pre></div><h4 id="為什麼要移除-remote">為什麼要移除 Remote？</h4>
<p>這是 <code>git filter-repo</code> 的<strong>安全機制設計</strong>，目的是保護你和你的團隊：</p>
<ol>
<li>
<p><strong>防止意外推送</strong>：重寫歷史後，所有 commit hash 都會改變。如果你不小心直接執行 <code>git push</code>，會把重寫後的歷史推送到遠端，可能覆蓋其他人的工作，造成嚴重問題。</p>
</li>
<li>
<p><strong>強迫你停下來思考</strong>：移除 remote 後，你必須：</p>
<ul>
<li>確認是否真的要推送重寫後的歷史</li>
<li>手動重新加入 remote</li>
<li>明確使用 <code>--force</code> 參數推送</li>
</ul>
</li>
<li>
<p><strong>給你機會通知團隊</strong>：在重新加入 remote 和 force push 之前，你有機會先通知其他協作者，讓他們做好準備。</p>
</li>
</ol>
<h4 id="如何處理">如何處理</h4>
<p>執行完 <code>git filter-repo</code> 後，依序執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 重新加入 remote</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git remote add origin &lt;repository-url&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 2. 確認 remote 已加入</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git remote -v
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 3. Force push（確認團隊已知情後再執行）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">git push origin --force --all</span></span></code></pre></div><h3 id="force-push">Force Push</h3>
<p>重寫歷史後需要 force push：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Push 所有分支</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git push origin --force --all
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Push 所有 tags</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git push origin --force --tags</span></span></code></pre></div><h3 id="通知協作者">通知協作者</h3>
<p>其他協作者需要執行以下操作來同步：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 方法一：重新 clone（推薦）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git clone &lt;repository-url&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 方法二：強制重設（注意：會丟失本地未 push 的變更）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git fetch --all
</span></span><span class="line"><span class="ln">6</span><span class="cl">git reset --hard origin/&lt;branch-name&gt;</span></span></code></pre></div><hr>
<h2 id="常見問題">常見問題</h2>
<h3 id="q-執行時出現-refusing-to-run-without-fresh-clone-錯誤">Q: 執行時出現 &ldquo;Refusing to run without fresh clone&rdquo; 錯誤</h3>
<p>這是安全機制，使用 <code>--force</code> 參數來覆蓋：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --invert-paths --path file.txt --force</span></span></code></pre></div><h3 id="q-如何還原操作">Q: 如何還原操作？</h3>
<p>如果有備份分支：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git checkout backup-before-filter
</span></span><span class="line"><span class="ln">2</span><span class="cl">git branch -D main
</span></span><span class="line"><span class="ln">3</span><span class="cl">git checkout -b main</span></span></code></pre></div><p>如果有備份 repository：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git remote add backup /path/to/backup-repo
</span></span><span class="line"><span class="ln">2</span><span class="cl">git fetch backup
</span></span><span class="line"><span class="ln">3</span><span class="cl">git reset --hard backup/main</span></span></code></pre></div><h3 id="q-為什麼我的-repository-大小沒有變小">Q: 為什麼我的 repository 大小沒有變小？</h3>
<p>執行以下命令來清理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git reflog expire --expire<span class="o">=</span>now --all
</span></span><span class="line"><span class="ln">2</span><span class="cl">git gc --prune<span class="o">=</span>now --aggressive</span></span></code></pre></div><h3 id="q-可以只影響特定分支嗎">Q: 可以只影響特定分支嗎？</h3>
<p>可以，使用 <code>--refs</code> 參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --invert-paths --path file.txt --refs main --force</span></span></code></pre></div><h3 id="q-如何預覽變更而不實際執行">Q: 如何預覽變更而不實際執行？</h3>
<p>使用 <code>--dry-run</code> 參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --invert-paths --path file.txt --dry-run</span></span></code></pre></div><hr>
<h2 id="參考資源">參考資源</h2>
<ul>
<li><a href="https://github.com/newren/git-filter-repo">官方文件</a></li>
<li><a href="https://htmlpreview.github.io/?https://github.com/newren/git-filter-repo/blob/docs/html/git-filter-repo.html">官方手冊</a></li>
<li><a href="https://github.com/newren/git-filter-repo/blob/main/Documentation/converting-from-filter-branch.md">常見使用案例</a></li>
</ul>
]]></content:encoded></item></channel></rss>