
When two changes need two working directories
An urgent fix and a new feature may need inspection at the same time. Repeatedly switching one working directory can interfere with open files, build output and unfinished edits. A worktree attaches another working directory to the same repository so each task has a named branch and a clear location. This tutorial uses a disposable fixture to demonstrate the difference between isolated working files and shared repository information.
Worktrees are not completely independent copies of everything. Git objects and some configuration and references remain shared, and administrative commands can affect the common repository. Working files are separate, but changing shared configuration or assuming independent secrets and hooks is unsafe reasoning. Give build artifacts, test databases and generated files their own paths as well, otherwise two clean checkouts can still interfere through external shared state.
What is isolated and what is still shared
create_fixture creates a fresh repository inside a temporary directory, configures the example commit identity locally and creates a feature branch in another directory. It never contacts a remote or reuses the reader's real repository. Both checkouts initially contain the same app.py file. Changing only the feature copy then provides a direct observable assertion: the main working file must keep its original content throughout the demonstration.
Cleanup begins with machine-readable porcelain status. Modified or untracked files stop the operation. The helper accepts only sibling children of its declared fixture root and invokes git worktree remove without force. Replacing that operation with arbitrary recursive filesystem deletion would discard the preservation contract. The example deliberately keeps the refusal visible so callers must save, commit or otherwise preserve unfinished work before trying cleanup again.
The main test proves three behaviors: feature edits do not change main, a dirty checkout cannot be removed through the helper, and ordinary removal succeeds after a commit. A second test rejects a path outside the fixture root before a Git command is executed. These checks are useful in automation too. A branch's existence is not evidence that every local file has been committed or that a working directory is safe to discard.
Complete files, execution and tests
Python 3.11+ and Git on PATH; the example operates only on a fresh temporary repository. Save the four complete files below together using the stated filenames; no code is replaced by ellipses.
1. A disposable repository and two working directories
File: worktree_fixture.py. Save this complete file without removing lines.
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, featurecreate_fixture creates a fresh repository. Commit identity, signing and hook configuration are local to the fixture; no remote is contacted.
2. Status checking and removal without force
File: worktree_check.py. Save this complete file without removing lines.
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')Paths are resolved and restricted to the fixture root. Any modification or untracked file raises RuntimeError and preserves the working directory.
3. Isolation and unfinished-work preservation tests
File: test_worktree.py. Save this complete file without removing lines.
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()Run python -m unittest test_worktree.py. The main file is compared before and after feature edits, and an outside-root path is refused.
4. A complete executable scenario
File: run_worktree_demo.py. Save this complete file without removing lines.
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')Run python run_worktree_demo.py. Expect main isolated, dirty worktree preserved and clean worktree removed. All fixture files live in a dedicated temporary directory.
Test preservation before removing a checkout
Save the four files together and run python -m unittest test_worktree.py. The demonstration prints confirmation of main isolation, preservation of dirty work and removal of a clean worktree. For a real project, define branch names, build paths and a policy for ignored files. The demonstration operates only in a temporary fixture and does not turn a decision about preserving unfinished work into an unconditional forced deletion.
Implementation checklist
- Run the assertion that main remains unchanged.
- Untracked files must also block cleanup.
- Remove worktrees without force.
- Review shared configuration, build output and databases separately.
References
Editorial guidance: Liyan Knowledge.



