وقتی دو تغییر به دو پوشهٔ کاری نیاز دارند

فرض کنید یک اصلاح فوری و یک قابلیت جدید باید هم‌زمان بررسی شوند. تغییر مداوم شاخه در یک پوشه می‌تواند فایل‌های باز، خروجی ساخت و تغییرات ذخیره‌نشده را درگیر کند. worktree یک پوشهٔ کاری دیگر را به همان مخزن متصل می‌کند تا هر کار روی شاخهٔ مشخص خود انجام شود. هدف این آموزش ایجاد یک محیط آزمایشی کوچک است که تفاوت جداسازی فایل‌ها و اشتراک اطلاعات مخزن را با آزمون نشان دهد.

worktreeها نسخه‌های کاملاً مستقل از همه‌چیز نیستند. پایگاه اشیای Git و بخشی از تنظیم‌ها و referenceها مشترک می‌ماند و بعضی عملیات مدیریتی روی مخزن مشترک اثر می‌گذارند. فایل‌های کاری هر پوشه جدا هستند، اما تغییر تنظیم سراسری یا فرض مستقل‌بودن اسرار و hookها تصمیم درستی نیست. برای ساخت، پایگاه داده و فایل‌های تولیدی نیز مسیر جدا تعریف کنید تا دو checkout از طریق خروجی‌های مشترک به هم وابسته نشوند.

آنچه جدا است و آنچه همچنان مشترک می‌ماند

نمونهٔ create_fixture یک مخزن تازه در پوشهٔ موقت می‌سازد، کاربر و تنظیم امضای commit را فقط در همان مخزن تنظیم می‌کند و یک شاخهٔ feature در پوشهٔ دیگر ایجاد می‌کند. هیچ remote و هیچ مخزن واقعی کاربر استفاده نمی‌شود. فایل app.py در هر دو checkout ابتدا یکسان است؛ سپس فقط نسخهٔ feature تغییر می‌کند. این ترتیب اجازه می‌دهد نتیجهٔ جداسازی را مستقیم از محتوای فایل شاخهٔ main بررسی کنیم.

برای پایان کار، ابتدا status با قالب porcelain خوانده می‌شود تا خروجی قابل پردازش باشد. وجود فایل تغییرکرده یا فایل untracked باعث توقف پاک‌سازی می‌شود. تابع فقط پوشه‌های فرزند ریشهٔ fixture را می‌پذیرد و git worktree remove را بدون force اجرا می‌کند. تبدیل این مرحله به حذف بازگشتی دلخواه فایل‌ها، قرارداد حفظ تغییرات را از بین می‌برد و در این مثال عمداً چنین مسیری وجود ندارد.

آزمون اصلی سه رفتار را ثابت می‌کند: تغییر feature محتوای main را تغییر نمی‌دهد، پاک‌سازی checkout کثیف رد می‌شود و پس از commit، حذف معمولی worktree موفق است. آزمون دوم مسیر بیرون از ریشهٔ آزمایش را پیش از هر فرمان Git رد می‌کند. این معیارها برای ابزارسازی نیز مفید هستند؛ وجود یک شاخه به‌تنهایی مجوز حذف پوشه‌ای با فایل‌های ذخیره‌نشده نیست و وضعیت فایل‌ها باید جداگانه بررسی شود.

فایل‌های کامل، روش اجرا و آزمون

Python 3.11+ و Git در PATH؛ اجرای مثال فقط روی مخزن موقت تازه انجام می‌شود. چهار فایل زیر را با همین نام‌ها در یک پوشه ذخیره کنید؛ هیچ بخش کد با سه‌نقطه جایگزین نشده است.

1. مخزن آزمایشی و دو پوشهٔ کاری

نام فایل: worktree_fixture.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from pathlib import Path
import subprocess


def git(directory, *arguments):
    return subprocess.run(['git', '-C', str(directory), *arguments], check=True,
                          text=True, capture_output=True, encoding='utf-8').stdout.strip()


def create_fixture(root):
    root = Path(root).resolve()
    root.mkdir(parents=True, exist_ok=True)
    repository, feature = root / 'repository', root / 'feature'
    repository.mkdir()  # Refuse to reuse an existing checkout.
    git(repository, 'init', '-b', 'main')
    git(repository, 'config', 'user.name', 'Liyan Example')
    git(repository, 'config', 'user.email', 'example@example.invalid')
    git(repository, 'config', 'commit.gpgsign', 'false')
    git(repository, 'config', 'core.hooksPath', str(root / 'unused-hooks'))
    (repository / 'app.py').write_text('VALUE = "main"\n', encoding='utf-8')
    git(repository, 'add', 'app.py')
    git(repository, 'commit', '-m', 'Initial fixture')
    git(repository, 'worktree', 'add', '-b', 'feature/csv', str(feature))
    return repository, feature

create_fixture فقط مخزن تازه می‌سازد. تنظیم‌های کاربر، امضا و hook در همان fixture قرار می‌گیرند و هیچ remote فراخوانی نمی‌شود.

2. بررسی وضعیت و حذف بدون force

نام فایل: worktree_check.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from pathlib import Path
from worktree_fixture import git


def remove_clean_worktree(repository, feature, root):
    root = Path(root).resolve()
    repository, feature = Path(repository).resolve(), Path(feature).resolve()
    if repository.parent != root or feature.parent != root or feature == repository:
        raise ValueError('The worktree must be a separate child of this fixture root')
    status = git(feature, 'status', '--porcelain=v1', '--untracked-files=all')
    if status:
        raise RuntimeError('Worktree contains changes; commit or preserve them first')
    # No --force and no recursive filesystem deletion.
    git(repository, 'worktree', 'remove', str(feature))
    return git(repository, 'worktree', 'list', '--porcelain')

مسیرها ابتدا resolve و به ریشهٔ آزمایش محدود می‌شوند. وجود هر تغییر یا فایل untracked، RuntimeError می‌دهد و محتوای پوشه را حفظ می‌کند.

3. آزمون جداسازی و حفظ کار نیمه‌تمام

نام فایل: test_worktree.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from pathlib import Path
import tempfile
import unittest
from worktree_fixture import create_fixture, git
from worktree_check import remove_clean_worktree


class WorktreeTests(unittest.TestCase):
    def test_isolation_and_non_destructive_cleanup(self):
        with tempfile.TemporaryDirectory(prefix='liyan-worktree-test-') as folder:
            repository, feature = create_fixture(folder)
            (feature / 'app.py').write_text('VALUE = "feature"\n', encoding='utf-8')
            self.assertEqual((repository / 'app.py').read_text(encoding='utf-8'), 'VALUE = "main"\n')
            with self.assertRaisesRegex(RuntimeError, 'contains changes'):
                remove_clean_worktree(repository, feature, folder)
            self.assertTrue(feature.exists())
            self.assertIn('feature', (feature / 'app.py').read_text(encoding='utf-8'))
            git(feature, 'add', 'app.py')
            git(feature, 'commit', '-m', 'Complete feature example')
            remaining = remove_clean_worktree(repository, feature, folder)
            self.assertFalse(feature.exists())
            self.assertIn('refs/heads/main', remaining)

    def test_outside_path_is_refused(self):
        with self.assertRaises(ValueError):
            remove_clean_worktree(Path('root/repository'), Path('outside/feature'), Path('root'))


if __name__ == '__main__':
    unittest.main()

python -m unittest test_worktree.py را اجرا کنید. فایل main پیش و پس از ویرایش feature مقایسه می‌شود و مسیر خارج از ریشه نیز رد می‌شود.

4. سناریوی کامل قابل اجرا

نام فایل: run_worktree_demo.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

import tempfile
from worktree_fixture import create_fixture, git
from worktree_check import remove_clean_worktree


with tempfile.TemporaryDirectory(prefix='liyan-worktree-demo-') as folder:
    repository, feature = create_fixture(folder)
    (feature / 'app.py').write_text('VALUE = "feature"\n', encoding='utf-8')
    assert git(repository, 'branch', '--show-current') == 'main'
    assert git(feature, 'branch', '--show-current') == 'feature/csv'
    print('main isolated')
    try:
        remove_clean_worktree(repository, feature, folder)
    except RuntimeError:
        print('dirty worktree preserved')
    git(feature, 'add', 'app.py')
    git(feature, 'commit', '-m', 'Save the feature')
    remove_clean_worktree(repository, feature, folder)
    print('clean worktree removed')

اجرای python run_worktree_demo.py باید سه پیام main isolated، dirty worktree preserved و clean worktree removed بدهد. همهٔ فایل‌های آزمایش در پوشهٔ موقت اختصاصی هستند.

آزمون حفظ تغییرات پیش از پاک‌سازی

چهار فایل را کنار هم ذخیره کنید و python -m unittest test_worktree.py را اجرا کنید. اجرای run_worktree_demo.py پیام‌های جداسازی main، حفظ تغییرات کثیف و حذف worktree تمیز را چاپ می‌کند. برای استفاده در پروژهٔ واقعی، نام شاخه، مسیر خروجی ساخت و قواعد نگهداری فایل‌های نادیده‌گرفته‌شده را مشخص کنید. این نمونه در محیط موقت اجرا می‌شود و تصمیم دربارهٔ نگهداری کار نیمه‌تمام را به یک حذف اجباری تبدیل نمی‌کند.

چک‌لیست اجرای عملی

  • آزمون تغییرنکردن main را اجرا کنید.
  • فایل‌های untracked نیز باید مانع پاک‌سازی شوند.
  • فرمان remove بدون force اجرا شود.
  • تنظیم، خروجی ساخت و پایگاه دادهٔ مشترک را جداگانه بررسی کنید.

منابع

تدوین و راهنمای عملی: دانشنامه لیان.