Worktrees and bigger projects
The interactive lesson needs JavaScript. The text below is the complete lesson.
After this lesson you can
- work on two branches at once with
git worktree add, and clean up afterwards withgit worktree removeorgit worktree pruneapply - write patterns in
.gitignoreand delete untracked files safely withgit clean -nbeforegit clean -fapply - explain what Git LFS and hooks solve, and what they do not guarantee understand
Understand: More folders, and things that do not belong in Git
A working tree shows one branch at a time. git worktree add creates an extra working tree for the same repository, here with a new branch, hotfix. It has its own HEAD, its own staging area and its own files, but commits and branches are shared.
A commit made in one folder is visible in the other at once, because it is the same repository. Here hotfix gets the commit H.
In bigger projects, files show up that should never go into Git, like logs and build output. Patterns in .gitignore make Git stop showing them as untracked. That applies only to files that are not already tracked, so .gitignore removes nothing from the history.
git clean deletes untracked files, and Git has no copy to restore them from. That is why you always run git clean -n first, which only shows what would be deleted. Untracked folders need -d, and ignored files stay unless you add -x.
Large binary files, like video or data sets, fit Git poorly. Every version stays in the history, and an ordinary clone downloads all of them. Git LFS is a separate add-on that stores a small pointer in Git and the file itself on a separate server.
Hooks are scripts Git runs at events, for example before a commit or a push. They live in your local .git folder, are not cloned and can be skipped. Rules the team must rely on belong in CI or on the server.
Watch: An urgent fix in a folder of its own
We are on feature in the middle of a search feature. search.txt is modified and not committed. Then an urgent fix comes in that must start from main.
$ git status --short
Instead of stashing, we create a new working tree next to this one, ../hotfix. With -b it gets a new branch, hotfix, starting at main. What happens to the unfinished change?
$ git worktree add -b hotfix ../hotfix main
git worktree list shows both folders, which commit each is on and which branch each has checked out. It is still just one repository.
$ git worktree list
We step into the new folder. The graph and the file panel still show the first folder, but it is the same repository.
$ cd /course/hotfix
Here we make the fix and commit it. Watch the graph: hotfix gets a new commit, even though we committed in another folder.
$ echo "Contact: support@example.com" >> README.md && git commit -am "Add support contact"
Then we go back to the first folder.
$ cd /course/you
The unfinished work is right where it was. We have not stashed, committed or switched branches here.
$ git status --short
The fix is committed on hotfix, so the folder is no longer needed. git worktree remove deletes the folder, but the branch and the commit stay. Afterwards you merge hotfix into main as usual.
$ git worktree remove ../hotfix
Tasks
- See which files have unfinished changes.
- Create an extra worktree
../hotfixwith a new branchhotfixstarting atmain. - List the repository's worktrees.
- Make the fix in the hotfix folder: add a line to README.md and commit it on
hotfix. - Remove the extra working folder, but keep the branch.
Watch: Ignore and clean up safely
A project quickly gathers clutter: a build folder, a log file and a scratch note. None of them should go into Git.
$ mkdir -p build && echo "binary" > build/app.bin && echo "debug output" > debug.log && echo "draft idea" > scratch.txt
Git shows all three as untracked. With many such files, the important changes drown in noise.
$ git status --short
We write two patterns in .gitignore. The first matches every file ending in dot log. The second matches the whole build folder.
$ printf '*.log\nbuild/\n' > .gitignore
.gitignore is an ordinary file. We commit it, so the whole team gets the same rules. Now only the scratch note is left as untracked.
$ git add .gitignore && git commit -m "Ignore logs and build output"
Before deleting anything, we ask what would disappear. -n means dry run. What do you think is on the list?
$ git clean -n
The list looked right, so we delete with -f. The scratch note is gone for good, while the log and the build folder remain.
$ git clean -f
Tasks
- Make Git ignore all log files and the whole build folder.
- Commit the ignore rules.
- See what Git would delete, without deleting anything.
- Delete the untracked scratch note, but leave the ignored files alone.
Practise: Transfer: a review and a clean-up
You are on main with an unfinished change in README.md. The branch docs already exists. The folder also has two untracked files, and last week you deleted an old worktree folder, ../old-review, by hand. No demo this time.
Tasks
- Git still remembers the old folder
../old-review, even though it is gone. Clean up Git's records of worktrees. - A colleague asks you to fix the guide on the branch
docsnow. Check out the existing branchdocsin a folder of its own,../docs, without touching your README change. - In the
docsfolder: add a line to guide.txt and commit it. Then remove the extra folder. The commit must stay ondocs. - Tidy the main folder. Make Git ignore every file ending in
.tmp, and commit the rule. Then delete the other untracked files. Your README change must still be there.
Check yourself: Recall it
The branch hotfix is checked out in ../hotfix. In the main folder you type git switch hotfix. What happens?
- Git switches, and both folders now show
hotfix - Git refuses, because the branch is already checked out in another worktree
- Git moves the folder
../hotfixhere
secret.env was committed last week. Today you add secret.env to .gitignore and commit. Is the secret safe now?
- Yes, ignored files are no longer pushed
- No. The file is still tracked, and the old commits contain the secret
- Yes, once
git clean -fXhas removed it
The project needs large video files that change often. What fits?
- Commit them as ordinary files, Git compresses them anyway
- Git LFS, which stores a pointer in Git and the content on a separate server
- Put them in
.gitignore, so they take up no space
The team wants to make sure code that does not compile never lands on main. Is a pre-push hook enough?
- Yes, hooks come along to everyone who clones
- No, hooks are local and can be skipped. The rule must be enforced on the server, with required CI checks and a protected
main - Yes, if the hook is committed in a
hooks/folder in the repository
Review from “Fetch, pull and push”: Ada makes a commit in her own clone and pushes it to main. When do you see it in your repository?
- At once, just like in a worktree
- As soon as she pushes, without you doing anything
- When you run
git fetch, and then asorigin/main
Common misconceptions
- “A worktree is a new clone.” All worktrees share the same repository, with the same commits and branches.
- “
git cleancan be undone.” Git keeps no saved copy of what untracked files contain now. What it deletes is gone. - “
.gitignorehides secrets.” It applies only to untracked files. Anything already committed is still in the history.
Read more
Worktrees: the full life cycle
Every worktree has its own HEAD, staging area and files, while the objects, branches, tags and config are shared. Git keeps a small record for each extra worktree in .git/worktrees/. The commands you need:
| Command | What it does |
|---|---|
git worktree add ../review docs |
check out an existing branch in a new folder |
git worktree add -b hotfix ../hotfix main |
create a new branch at main and check it out there |
git worktree add --detach ../try v1.0 |
look at a commit without any branch |
git worktree list |
show every worktree, its commit and branch |
git worktree remove ../review |
delete the folder and the record; refuses if it has modified or untracked files |
git worktree prune |
forget records whose folder has disappeared |
git worktree lock --reason "on USB disk" ../review |
protect a record from pruning, for folders on removable disks |
git worktree move ../review ../old-review |
move a worktree folder |
If you delete a worktree folder by hand, Git still remembers it, and the branch stays "in use":
$ git worktree list
/course/you 78e95ce [main]
/course/hotfix 78e95ce [hotfix]
/course/review 78e95ce [docs] prunable
$ git worktree prune --dry-run --verbose
Removing worktrees/review: gitdir file points to non-existent location
$ git worktree prune
A branch can be checked out in only one worktree at a time. Try it elsewhere and Git refuses with "fatal: 'hotfix' is already used by worktree at …". git worktree remove refuses a folder with modified or untracked files; adding --force deletes them for good, so commit or stash first.
Ignore rules in detail
.gitignore patterns are read line by line, and the last matching rule wins:
*.logmatches in every folder;/todo.txtwith a leading slash only at the top.build/with a trailing slash matches only folders.**/tmpmatchestmpat any depth.!keep.logre-includes a file an earlier rule ignored, unless its whole folder is ignored.#starts a comment.
Rules for the whole team go in the committed .gitignore. Personal rules go in .git/info/exclude or in a global file set with core.excludesFile. When you wonder why a file is ignored, ask Git:
$ git check-ignore -v build/output.log
.gitignore:2:build/ build/output.log
Ignore rules apply only to untracked files. To stop tracking a file that is already committed, run git rm --cached secret.env, add it to .gitignore and commit. The file stays in the history, so a leaked secret must be revoked and replaced, not just ignored.
git clean options
git clean needs -f to delete anything. -n shows what would go, -d includes untracked folders, -x also deletes ignored files, -X deletes only ignored files (handy for removing build output), and -i asks interactively. Always run the same flags with -n first.
.gitattributes: line endings and binary files
Windows writes CRLF line endings, macOS and Linux LF. A shared .gitattributes makes the repository consistent no matter who commits:
$ printf '* text=auto\n*.sh text eol=lf\n*.png binary\n' > .gitattributes
$ git check-attr -a -- src/app.sh
src/app.sh: text: set
src/app.sh: eol: lf
text=auto lets Git store text files with LF; eol=lf keeps shell scripts LF in the working tree too, and binary turns off diffs and line-ending conversion. When you introduce such rules in an existing project, run git add --renormalize . and commit the result alone, as a purely mechanical commit. Never mix it with a real change, or nobody can review the real change.
Bigger repositories
- Sparse checkout materialises only some folders in the working tree. History and objects are still there.
git sparse-checkout set docslimits the files todocsand the top-level files,git sparse-checkout disablebrings everything back. - Partial clone downloads fewer objects up front:
git clone --filter=blob:none URLfetches commits and trees, and file contents on demand. The server must support it. - Shallow clone with
--depth 1cuts off history. It is quick for CI, but log, blame and bisect cannot see past the cut. - Git LFS is a separate tool. It replaces large files with small pointer files in Git and keeps the content on an LFS server. Everyone needs the LFS client and the server must support it. It does not shrink history that already contains the large files.
- Submodules pin another repository at one exact commit. The outer repository stores only that commit ID, as a special entry:
$ git submodule add https://example.com/lib.git vendor/lib
$ git ls-files --stage vendor/lib
160000 2ac14ea53dae7e4f874f013b705d31dc30124a99 0 vendor/lib
People who clone need git clone --recurse-submodules, or git submodule update --init afterwards. Moving the pin to a newer commit is a deliberate commit in the outer repository.
Hooks and what they guarantee
Hooks are executable scripts in .git/hooks (or a folder set with core.hooksPath) that Git runs at events like pre-commit, commit-msg and pre-push. They are not cloned, each person has to install them, and --no-verify skips the pre-commit and commit-msg hooks on git commit and the pre-push hook on git push. Use them for fast local feedback. Rules everyone must follow, like "tests pass" or "no direct pushes to main", belong in CI and in branch protection on the server. Likewise, a signed commit or tag proves which key signed it, not that the content is correct or that the key was not stolen.
Maintenance and portable backups
git fsck checks the object database for corruption, git count-objects -v shows its size, and git gc packs it, which Git also does automatically. Do not run aggressive pruning to "fix" a problem you have not diagnosed: it removes exactly the unreachable objects you might need to recover. A bundle packs the refs you name, here all of them, and the commits they reach into one file:
$ git bundle create ../backup.bundle --all
$ git bundle verify ../backup.bundle
...
../backup.bundle is okay
You can clone or fetch from a bundle like from a remote. It contains commits and refs only: uncommitted work, the reflog, hooks and local config are not in it.
Per-Arne's Git Good!