You are halfway through editing a business app when an AI coding task needs a different change. Giving both jobs the same checkout makes it harder to tell which files belong to which task. A Git worktree gives the new task its own folder and branch while your unfinished work stays where it is.

This walkthrough creates that separation, tests a small booking fix, reviews its exact changes and integrates the commit into a separate review branch. The original checkout remains unfinished and unchanged throughout. It is a useful pattern for delegating a bounded coding task without first packing away everything on your desk.

Test scope: Harbor Service is a fictional business. The example applies a prepared patch to test the Git workflow; no named AI coding assistant was run or benchmarked. The featured image is AI-generated conceptual artwork, not a screenshot.

Choose the starting commit before assigning the task

A linked worktree has a separate checkout and index while sharing the underlying repository. The explicit main argument below selects the starting branch; it does not copy unfinished files from your current folder. Git also normally refuses to check out the same branch in two worktrees. These behaviors are documented in Git’s worktree reference.

For Harbor Service, the committed app has a broken appointment-length function. The owner has separately edited the README and created an untracked notes file. Those unfinished changes are unrelated to the booking fix. Starting the coding task from committed main is deliberate.

If the task actually needs your uncommitted changes, stop and decide what reviewed checkpoint it should start from. Otherwise the assistant will see an older version of the application. Also confirm which commit your local branch contains: a local main is not a promise that it matches a remote repository.

Prerequisites: Git on PATH, Python and a repository with at least one commit. The complete fixture below creates its own repository and needs no API key, remote or third-party Python packages. It was run on Windows 11 with Git 2.51.0.windows.1 and Python 3.14.3; these are tested versions, not a claim about the newest releases.

Create a named work area and a narrow assignment

In the fictional harbor-app checkout, use the following pattern. Check the status and starting commit, then open the new folder in your coding assistant. The sibling folder and new branch must not already exist.

git status --short
git rev-parse main
git worktree add -b agent/booking-duration ../agent-booking main
git -C ../agent-booking branch --show-current
git -C ../agent-booking status --short

The requirement is small: appointments can last 30, 60, 90 or 120 minutes, supplied as built-in integers. The starter silently converts values with int(value). That wrongly accepts numeric strings, floats and booleans, as well as unsupported lengths.

Give the assistant a concrete contract and stopping point. Save or adapt this task prompt; it is an instruction to the assistant, not an operating-system access restriction.

Work only in the agent-booking checkout on branch agent/booking-duration.
Read the repository instructions and confirm the current directory and branch.
For fictional Harbor Service, duration_minutes must accept only built-in integers
30, 60, 90 and 120. Raise ValueError for every other value, including booleans,
floats, numeric strings and None. Do not silently coerce inputs.
Change only booking.py and test_booking.py. Preserve the function name.
Demonstrate a failing regression check, then run the full test file after fixing.
Do not change sibling checkouts, Git configuration, deployment or credentials.
Stop with a diff and test results for review; do not merge, push or remove worktrees.

To standardize that handoff across recurring projects, see Build a Reusable Agent Skill for Business Project Briefs. Keep the task-specific branch, files and acceptance checks explicit.

Claude Code also documents a --worktree option for separate sessions in its common workflows guide. That convenience option was not tested here. The fixture uses Git directly so its evidence does not depend on a particular assistant’s automatic setup or cleanup behavior.

Test the fix, then review all three kinds of change

The fixture adds the regression tests before applying the patch and confirms that they fail. After the fix, three test methods cover 15 input values: four accepted lengths, six unsupported lengths and five values that must not be coerced. It repeats those tests after integration.

The complete corrected function is small enough to inspect:

"""Fictional Harbor Service booking rules for fixed service appointments."""


def duration_minutes(value):
    # Accept only a built-in integer from the application's fixed menu.
    if type(value) is not int or value not in {30, 60, 90, 120}:
        raise ValueError("Choose 30, 60, 90 or 120 minutes")
    return value

From the original harbor-app folder, inspect the task’s status and unstaged edits. New untracked files appear in status but are not included in an ordinary diff; open them too. Stage only the files approved for this task, then inspect what will actually be committed.

git -C ../agent-booking status --short
git -C ../agent-booking diff
git -C ../agent-booking add booking.py test_booking.py
git -C ../agent-booking diff --cached --name-only
git -C ../agent-booking diff --cached
git -C ../agent-booking diff --cached --check
git -C ../agent-booking commit -m "Reject unsupported appointment lengths"
git diff main...agent/booking-duration

These views answer different questions. Ordinary git diff shows unstaged tracked changes; --cached shows staged changes. The final three-dot comparison shows committed branch changes since the merge base. It cannot tell you whether an assistant left additional uncommitted files behind. See the Git diff reference for those distinctions.

Passing --check is only a whitespace/error-marker check, not a correctness or security review. Read the patch and test results before committing. In the automated fixture, assertions verify that the staged file list is exactly booking.py and test_booking.py; the reviewed patch is saved as evidence.

Check integration without disturbing the owner’s checkout

The example starts a second, clean worktree on review/booking-duration. That branch receives the feature commit; the owner’s main branch does not move. From harbor-app:

git worktree add -b review/booking-duration ../review-booking main
git -C ../review-booking merge --ff-only agent/booking-duration

Then, in the review-booking folder, run python -B -m unittest -v test_booking.py. The -B option keeps bytecode cache files out of this small fixture. A real application should run its relevant integration checks as well.

--ff-only refuses to merge when fast-forwarding is impossible; it does not resolve divergent work. That behavior is specified in the Git merge documentation. If the base has moved in a real project, stop and review the history before deciding how to update the feature branch. This demonstration covers a straight-line integration, not conflicts or concurrent writers.

For a team repository, the next step is the project’s normal pull-request and release process. A green review branch is evidence for that decision. It does not deploy anything or make the unfinished original checkout ready to switch branches.

Remove only clean worktrees

Return to harbor-app and check both worktrees for remaining files before removing them. Review ignored local files too: ordinary short status does not list them. Keep anything you need before cleanup.

git -C ../agent-booking status --short --ignored
git -C ../review-booking status --short --ignored
git worktree list
git worktree remove ../agent-booking
git worktree remove ../review-booking

The fixture uses absolute paths validated inside its newly created scratch directory, confirms clean status and uses Git’s normal removal command without force. An earlier attempt to remove the dirty task worktree is deliberately refused. Both branch references remain after their clean folders are removed, and the original checkout still has its README edit and notes.

Run the complete reproducible fixture

Save these four files in a new empty folder: booking_before.py, booking_after.py from the function block above, test_booking.py and reproduce.py. The prompt is optional reference material. Run the command below from that folder; do not substitute an existing application directory.

Complete booking_before.py
"""Fictional Harbor Service booking rules, intentionally buggy starter."""


def duration_minutes(value):
    return int(value)
Complete test_booking.py
import unittest
from booking import duration_minutes


class BookingTests(unittest.TestCase):
    def test_supported_lengths(self):
        for value in (30, 60, 90, 120):
            with self.subTest(value=value):
                self.assertEqual(duration_minutes(value), value)

    def test_bad_lengths(self):
        for value in (0, -30, 15, 45, 121, 180):
            with self.subTest(value=value), self.assertRaises(ValueError):
                duration_minutes(value)

    def test_no_coercion(self):
        for value in (True, False, 30.0, "30", None):
            with self.subTest(value=value), self.assertRaises(ValueError):
                duration_minutes(value)


if __name__ == "__main__":
    unittest.main(verbosity=2)
Complete reproduce.py
"""Exercise the article in a NEW scratch folder; never use an existing repo."""
import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path

P = Path(__file__).resolve().parent
DEMO = (P / "demo").resolve()
if DEMO.exists():
    raise SystemExit("demo already exists; preserve it and use a fresh copied example folder")
DEMO.mkdir()
APP, AGENT, REVIEW = [(DEMO / name).resolve() for name in ("harbor-app", "agent-booking", "review-booking")]
LOG = []
env = os.environ.copy()
# The fixture must not inherit variables pointing at an unrelated repository.
env = {k: v for k, v in env.items() if not k.upper().startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull,
           GIT_TERMINAL_PROMPT="0", PYTHONDONTWRITEBYTECODE="1")


def run(cwd, *args, ok=True):
    r = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True,
                       encoding="utf-8", errors="replace")
    LOG.append({"cwd": str(cwd.relative_to(P)), "command": list(args),
                "exit": r.returncode, "stdout": r.stdout, "stderr": r.stderr})
    (P / "execution-log.json").write_text(json.dumps(LOG, indent=2), encoding="utf-8")
    if ok and r.returncode:
        raise RuntimeError(f"Command failed: {args[0:3]}; see execution-log.json")
    return r


def git(cwd, *args, ok=True):
    return run(cwd, "git", *args, ok=ok)


def write(path, value):
    path.write_text(value, encoding="utf-8")


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def remove_clean_worktree(path):
    # Verify the exact absolute removal target remains in this new scratch root.
    target = path.resolve()
    if target.parent != DEMO or target not in (AGENT, REVIEW):
        raise ValueError("Refusing an unexpected cleanup target")
    if git(target, "status", "--porcelain").stdout.strip():
        raise ValueError("Refusing cleanup with uncommitted files")
    git(APP, "worktree", "remove", str(target))


APP.mkdir()
git(APP, "init", "-b", "main")
git(APP, "config", "user.name", "AlgoThoughts Fixture")
git(APP, "config", "user.email", "fixture@example.invalid")
git(APP, "config", "core.autocrlf", "false")
shutil.copyfile(P / "booking_before.py", APP / "booking.py")
write(APP / "README.md", "# Fictional Harbor Service\nCommitted starter.\n")
git(APP, "add", "booking.py", "README.md")
git(APP, "commit", "-m", "Create fictional booking starter")
base = git(APP, "rev-parse", "HEAD").stdout.strip()
write(APP / "README.md", "# Fictional Harbor Service\nUnfinished homepage wording.\n")
write(APP / "owner-notes.txt", "Fictional owner's unfinished local notes.\n")
before_status = git(APP, "status", "--porcelain").stdout
before_hashes = {n: digest(APP / n) for n in ("booking.py", "README.md", "owner-notes.txt")}

git(APP, "worktree", "add", "-b", "agent/booking-duration", str(AGENT), "main")
assert git(AGENT, "rev-parse", "HEAD").stdout.strip() == base
assert "Committed starter" in (AGENT / "README.md").read_text()
assert not (AGENT / "owner-notes.txt").exists()
duplicate = git(APP, "worktree", "add", str(DEMO / "duplicate-checkout"), "agent/booking-duration", ok=False)
assert duplicate.returncode != 0
shutil.copyfile(P / "test_booking.py", AGENT / "test_booking.py")
red = run(AGENT, sys.executable, "-B", "-m", "unittest", "-v", "test_booking.py", ok=False)
assert red.returncode != 0
shutil.copyfile(P / "booking_after.py", AGENT / "booking.py")
green = run(AGENT, sys.executable, "-B", "-m", "unittest", "-v", "test_booking.py")
dirty_target = AGENT.resolve()
if dirty_target.parent != DEMO or dirty_target != AGENT:
    raise ValueError("Refusing an unexpected dirty-removal test target")
dirty_remove = git(APP, "worktree", "remove", str(dirty_target), ok=False)
assert dirty_remove.returncode != 0 and AGENT.exists()
git(AGENT, "add", "booking.py", "test_booking.py")
assert set(git(AGENT, "diff", "--cached", "--name-only").stdout.splitlines()) == {"booking.py", "test_booking.py"}
git(AGENT, "diff", "--cached", "--check")
git(AGENT, "commit", "-m", "Reject unsupported appointment lengths")
feature = git(AGENT, "rev-parse", "HEAD").stdout.strip()
patch = git(APP, "diff", "main...agent/booking-duration").stdout
write(P / "reviewed-change.patch", patch)
assert before_status == git(APP, "status", "--porcelain").stdout
assert before_hashes == {n: digest(APP / n) for n in before_hashes}
assert git(APP, "rev-parse", "HEAD").stdout.strip() == base

git(APP, "worktree", "add", "-b", "review/booking-duration", str(REVIEW), "main")
git(REVIEW, "merge", "--ff-only", "agent/booking-duration")
run(REVIEW, sys.executable, "-B", "-m", "unittest", "-v", "test_booking.py")
assert git(REVIEW, "rev-parse", "HEAD").stdout.strip() == feature
remove_clean_worktree(AGENT)
remove_clean_worktree(REVIEW)
assert git(APP, "rev-parse", "agent/booking-duration").stdout.strip() == feature
assert git(APP, "rev-parse", "review/booking-duration").stdout.strip() == feature
assert before_status == git(APP, "status", "--porcelain").stdout
assert before_hashes == {n: digest(APP / n) for n in before_hashes}
assert git(APP, "rev-parse", "HEAD").stdout.strip() == base
listing = git(APP, "worktree", "list", "--porcelain").stdout
assert listing.count("worktree ") == 1
evidence = {"git": git(APP, "--version").stdout.strip(), "python": platform.python_version(),
    "os": platform.platform(), "base_commit": base, "feature_commit": feature,
    "main_branch_unchanged": True, "main_status_unchanged": True,
    "main_file_hashes_before_and_after": before_hashes,
    "uncommitted_readme_not_copied": True, "untracked_notes_not_copied": True,
    "same_branch_second_checkout_refused": True, "dirty_worktree_removal_refused": True,
    "initial_regression_failed": True, "fixed_test_methods_passed": 3,
    "input_values_checked": 15, "integration_test_methods_passed": 3,
    "fast_forward_in_review_branch": True, "clean_worktrees_removed": True,
    "feature_and_review_branches_retained": True, "final_worktree_count": 1,
    "named_ai_cli_executed": False,
    "limits": ["Prepared patch applied in a scratch repository; not a model comparison",
               "No remote, production merge, deployment, dependency install or secret copying",
               "Worktree separation does not sandbox processes, credentials or external services"]}
write(P / "test-evidence.json", json.dumps(evidence, indent=2))
print(json.dumps(evidence, indent=2))
python -X utf8 reproduce.py

The runner refuses to start if its demo folder already exists. It creates a local-only test repository, applies the prepared patch, checks the workflow and writes execution-log.json, reviewed-change.patch and test-evidence.json. For another run, copy the four source files into a new empty folder and leave the earlier evidence intact. Git identity settings are local to the fictional repository; inherited Git configuration is disabled for the fixture subprocesses.

Observed check September 26 result
Regression against buggy starter Failed as expected
Fixed implementation 3 test methods passed, covering 15 values
Separate review branch after fast-forward The same 3 methods passed
Original checkout before and after Same HEAD, status and SHA-256 hashes for all 3 files
Unfinished README and untracked notes Not copied into the new task checkout
Duplicate branch checkout and dirty removal Both refused by Git
Final cleanup 1 worktree left; feature and review branches retained

Where the separation stops

A worktree separates files and staging for a task. It does not sandbox the process running there. An assistant or command with filesystem access may still read sibling folders, credentials or shared Git configuration, or run commands against another checkout. The task prompt is not a security boundary.

App resources need their own plan too. Two development servers can collide on the same port. Two checkouts can target the same database, queue or cloud account. Use separate local test resources when the task writes data; avoid copying production secrets into a convenience folder. The fixture has no database, server or external service and tests none of those integrations.

The practical win is a smaller unit of review: one task, one branch, a known starting commit and a patch whose tests can be repeated. For a business owner building with AI, that makes unfinished work easier to preserve and the decision to accept a change easier to inspect.

Jason Ead publishes AlgoThoughts for people building useful business technology. My Biz Heroes connects that focus with websites, automation, workflows, AI tools and training. Read more about Jason and the publication.