← Front-End Developer — all chapters  ·  Kelick Tech Hub

Chapter 24 — Git for Python Projects

Section 1: What Version Control Is For

Version control is a system that records changes to a project's files over time, so that you can recall any earlier version, compare versions, and understand who changed what and why. Without version control, a project's history lives in scattered copies, with names like final, final2, and really-final, which quickly become confusing, and where recovering an earlier version depends on remembering which copy holds it. With version control, history is stored systematically, in a repository, where each change is recorded as a commit, with a message explaining it, a timestamp, and an author, forming a timeline you can browse, search, and return to at any point. That timeline is invaluable, because it turns a project's past from a pile of copies into an organised record that can be inspected and trusted.

Version control also protects against loss and mistakes. If you delete a file accidentally, or change code in a way that breaks it, you can recover the earlier version from history, without needing a separate backup, provided the change was committed before the mistake. If an experiment goes wrong, you can discard it and return to the last good version, without manually undoing each change. That safety encourages experimentation, because the cost of a failed experiment is low: you can try an idea, and if it does not work, you can revert cleanly, rather than being stuck with a half-changed project. For a learner, that safety is liberating, because it removes the fear that trying something new will permanently damage a working program, which is a fear that otherwise discourages exploration and learning.

Collaboration is the third major benefit. When several people work on the same project, version control coordinates their changes, by tracking each person's commits, by merging changes that affect different parts of the code, and by flagging conflicts where two people changed the same lines in incompatible ways, so that those conflicts can be resolved deliberately, rather than silently overwriting each other's work. Without version control, collaboration requires manual coordination, such as emailing files back and forth, which is error-prone and quickly becomes unmanageable as a team grows. With version control, each person works in their own copy, commits their changes, and shares them through a central repository, where they are integrated systematically. That workflow, though it takes learning, is the standard for professional software development, and it scales from two-person teams to thousands of contributors.

Finally, version control provides documentation of a project's evolution. Commit messages, read in sequence, tell the story of how a project grew: which features were added, which bugs were fixed, which decisions were made, and why. That story helps new team members understand a project, helps maintainers remember why a particular piece of code exists, and helps future developers avoid repeating mistakes that were already fixed. A project with a clear, well-written history is easier to maintain than one with a cryptic or missing history, because its past is legible. Learning version control is therefore not just learning a tool; it is learning a practice of recording your work in a way that protects it, shares it, and explains it, which is a core professional skill for any Python developer.

Check: How does version control improve on keeping multiple named copies like final and final2?

Section 2: Repositories and Working Copies

A repository, often shortened to repo, is the store where version control keeps a project's history, including all its commits, branches, and metadata, in a hidden folder inside the project directory. Your working copy is the set of files you actually edit: the visible project files, in their current state, which may include changes you have made but not yet committed. The distinction between repository and working copy is fundamental: the repository holds the recorded history, while the working copy holds your current, possibly unrecorded, work. When you commit, you record the current state of selected files from your working copy into the repository, creating a new point in history. When you check out an earlier version, you update your working copy to match that recorded state, discarding or stashing uncommitted changes as needed. Understanding that two-layer model, recorded history versus current work, prevents confusion about where your changes live and whether they are safe.

Creating a repository is usually done by initialising version control in a project folder, which creates the hidden repository folder and prepares the project for tracking. That initialisation does not immediately record anything; it merely sets up the structure, and you must then add files and make an initial commit to record the project's starting point. After initialisation, version control tracks changes to files you have added, but it does not automatically track every file in the folder, especially files you have chosen to ignore, such as environment folders or generated files, which are excluded through an ignore file discussed later. Tracked files are those already in the repository, whose changes version control monitors. Modified files are tracked files you have changed in the working copy but not yet staged or committed. Staged files are changes you have marked for the next commit, through an add step, forming a snapshot of what the commit will contain. Untracked files are files in the folder that version control does not yet know about, such as new files you created, which will not be recorded until you add them. Those four states, checked with a status command, tell you exactly where your work stands: what is recorded, what is changed but unrecorded, what is ready to commit, and what is new and unknown. Checking status frequently, before committing and before switching versions, is a habit that prevents lost work and mistaken commits.

Finally, a repository can exist locally, on your own computer, without any remote server, and that local repository is fully functional for recording history, branching, and reverting, even without network access. A remote repository, hosted on a service or another computer, is an additional copy that enables sharing and backup, but it is not required for basic version control. That local-first design is powerful, because it means you can use version control for personal projects, for learning, and for offline work, without needing an account or a connection, and you can add a remote later, when you want to share or back up. Starting locally, learning the basic workflow, and adding remotes when ready is a sensible progression for a learner, and it mirrors how many professional projects begin, as local repositories that later gain remotes for collaboration.

Check: What is the difference between a repository and a working copy?

Section 3: Starting a Repository

Starting a repository for a Python project involves a few deliberate steps that set up good practices from the beginning, rather than retrofitting them later. First, organise the project folder, with code, tests, documentation, and configuration in clear places, and with a virtual environment folder that will be ignored, as discussed in the previous chapter. Then initialise version control in that folder, creating the repository structure. At this point, the folder contains a hidden repository, but no recorded history yet, and the next steps, adding files and committing, will create that history. Initialising in a folder that already contains unrelated files, or in a parent folder that contains many projects, can cause confusion, because version control will consider everything under that folder as part of one project, so initialise in the specific project folder, not in a broader folder that contains several projects, unless you intentionally want them as one project.

After initialisation, create an ignore file, before adding files, so that unwanted files are excluded from the start. For a Python project, that file typically excludes the virtual environment folder, bytecode caches, editor backup files, operating system files, and any data files or secrets that should not be shared, as discussed in detail later. Creating the ignore file first prevents accidental addition of those files, which, once committed, are harder to remove cleanly from history, especially if the project is later shared, because removing a file from future commits does not remove it from past commits, where it remains visible to anyone with the history. Early ignore setup is therefore a small step with a large benefit, and it should be part of every new project's setup checklist, alongside creating an environment and a requirements file.

Next, add the project's core files, such as code, tests, readme, requirements, and the ignore file itself, and make an initial commit with a clear message, such as initial commit, or a slightly more descriptive message stating what the project is. That initial commit should contain only files that belong in the project, not environment folders, not personal notes that were not meant to be shared, and not large data files that are not part of the code. That review habit, applied to the initial commit and to every later commit, prevents the common mistake of committing unintended files, which can include sensitive data or clutter that is embarrassing or difficult to remove later.

Finally, after the initial commit, verify the repository by checking its log, which should show the initial commit, and by checking its status, which should show a clean working copy, meaning no uncommitted changes, if you have committed everything you intended. A clean status after the initial commit confirms that the repository accurately reflects the project folder's intended state, and it gives you a known good starting point for future work. From there, development proceeds in a cycle: change files, check status, stage changes, commit with a message, and repeat, building history incrementally. Starting a repository well, with organisation, ignore setup, careful initial addition, and verification, sets a standard for the project's history that will pay off throughout its life, making it easier to maintain, share, and understand.

Check: Why should you create an ignore file before adding files to a new repository?

Section 4: Checking Status

Checking status is the most frequently used version control command, because it tells you, at a glance, what has changed in your working copy, what is staged for commit, and what is untracked, forming the basis for every other action. A status report typically lists files in categories: modified tracked files, staged files, untracked files, and sometimes ignored files, depending on the tool's settings. Reading status before committing, before switching branches, and before pulling changes from others prevents mistakes, such as committing a file you did not intend to include, discarding changes you meant to keep, or switching versions with uncommitted work that might be lost. Status is your map of current work, and consulting it regularly, like checking a compass, keeps you oriented in a project, especially when you return after a break and cannot remember what you were working on.

Status output deserves careful reading, not just a glance. A file listed as modified means its tracked version differs from its working copy version, but it does not tell you how it differs, which requires a diff command, discussed later. A file listed as staged means its changes have been marked for the next commit, but staging does not mean committed; staged changes are still only in your working copy's staging area, not yet in history, and they will be lost if you discard them without committing. A file listed as untracked means version control does not yet know about it, and it will not be included in commits until you add it, which is important for new files, such as a new module or test file, that you might assume are tracked because they are in the folder, when they are actually invisible to version control until added. Each category implies a different next action, and misreading a category can lead to a missed file in a commit, or to an unintended inclusion.

Status also helps you notice unexpected changes, such as files modified by a tool, by an editor, or by a script, that you did not intend to change, or files that appear as untracked but should be ignored, indicating that your ignore file is incomplete. Noticing such unexpected entries early, before committing, lets you investigate and correct them, such as by adding an ignore pattern, by reverting an unintended modification, or by adding a genuinely new file that belongs in the project. Check status when you start work, to see what was left uncommitted from a previous session, when you finish a task, to see what files it touched, and when you are confused about a project's state, to ground yourself in facts rather than in memory. That habit takes seconds, and it prevents a large proportion of version control mistakes, which often arise from acting on an assumed state that differs from the actual state. A developer who checks status frequently works with evidence about their project's state, while one who rarely checks works with assumptions, and assumptions, as we have seen in debugging, are a common source of errors. Status is the simplest, most useful version control command, and mastering its reading is the foundation for all other version control skills.

Check: What four categories can a status report show, and what does each imply for your next action?

Section 5: Staging Changes

Staging is the step between modifying files and committing them, where you select which changes will be included in the next commit, forming a precise snapshot of that commit's contents. Staging matters because it lets you craft commits that are focused and coherent, rather than committing everything you have changed at once, which may include unrelated changes, experiments, or fixes that belong in separate commits. For example, if you fixed a bug and also started a new feature in the same session, staging lets you commit the bug fix separately, with its own message, and leave the feature changes unstaged for a later commit, keeping history clean and understandable. That separation, achieved through staging, is a key skill for writing good history, because it turns a messy working session into a series of clear, logical commits, each telling one story.

Staging can be done at file level, by adding whole files, or at a finer level, by adding specific parts of a file, such as individual changes within a file that belong to different commits. File-level staging is sufficient for most work, where each file's changes belong to one commit, but finer staging becomes useful when a single file contains changes for two different purposes, such as a bug fix and a refactoring, that should be committed separately for clarity. Learning finer staging takes practice, and it requires careful review of diffs, to ensure you stage exactly the changes you intend, but it is a valuable skill for maintaining a clean history, especially in larger projects where files are touched for multiple reasons in one session. Start with file-level staging, and add finer staging as you become comfortable with diffs and with the concept of focused commits.

Staging is not committing, and that distinction is important. Staged changes are still in your working copy, in a staging area, not yet in the repository's history, and they can be unstaged, modified further, or discarded, without affecting history. That reversibility makes staging a safe place to prepare a commit: you can stage, check status and diff of staged changes, adjust staging if you included something unintended, and only then commit, when you are satisfied that the staged snapshot is exactly what you want to record. Staging plus review is therefore a quality gate for commits, similar to reviewing code before sharing it.

Finally, staging interacts with the working copy in a way that can confuse beginners: after staging a file, if you modify it further, the working copy will show both staged changes, from the earlier version, and unstaged changes, from the later modification, for the same file. That split state means the next commit will include only the staged version, not the later modification, unless you stage again. Understanding that split, by checking status and diffs for both staged and unstaged changes, prevents the mistake of committing a partial version and believing you committed the latest version, which can cause a collaborator to receive an incomplete change, or can cause you to lose track of which changes are recorded and which are not. Always check status after staging, and stage again after further modifications if you intend them to be included, so that your commit matches your intention.

Check: Why is staging useful for crafting focused commits, rather than committing everything at once?

Section 6: Making a Commit

A commit records a staged snapshot as a new point in history, with a message, an author, and a timestamp, making those changes permanent in the repository, in the sense that they can be recalled, compared, and shared, even if later changes overwrite them in the working copy. Making a commit is a deliberate act: you have staged the changes you intend to record, you have reviewed them, and you now create a commit that says, in effect, this set of changes, with this explanation, is a meaningful step in the project's development. That deliberateness distinguishes a commit from a save in an editor, which merely updates a file, without recording history, explanation, or a point to return to. Commits are the units of history, and their quality determines the history's usefulness, so making commits thoughtfully, rather than hastily, is a core version control skill.

A good commit is small, focused, and complete. Complete means it includes everything needed for its purpose to work, such as code plus its tests, or code plus its documentation update, so that the project remains in a working state after the commit, rather than being broken until a later commit completes the change. A history of small, focused, complete commits is easy to browse, easy to review, and easy to revert selectively, while a history of large, mixed, incomplete commits is difficult in all those respects.

Before committing, run relevant checks, such as tests for the changed code, to ensure the commit does not break the project, because a commit that breaks tests, even if it will be fixed later, pollutes history with a broken state that collaborators might check out and struggle with. That pre-commit check, running tests or at least a quick smoke test of the changed functionality, is a habit that keeps history healthy, by ensuring each commit is a working point, not just a snapshot of work in progress. If tests fail, fix them before committing, or, if the work is genuinely incomplete, consider whether it should be committed at all, or whether it should remain unstaged until it is complete, or be committed on a separate branch where incomplete work is expected, as discussed later. Committing broken code to a shared branch is a team problem, because it blocks others, while committing it to a personal branch is less problematic, provided it is clearly marked as work in progress.

After committing, verify the commit by checking the log, which should show the new commit with its message, and by checking status, which should show a clean working copy, if you committed all intended changes, or show remaining unstaged changes, if you deliberately left some for later. That verification confirms that the commit captured what you intended, and that no changes were left behind unintentionally. If verification reveals a mistake, such as a missing file or a wrong message, it can often be corrected before sharing, through amending or other history editing, discussed later, but only if the commit has not yet been shared, because shared history should not be rewritten without coordination. Verification immediately after committing, while the work is still fresh, makes correction easy, while verification delayed until later, when you have moved on to other work, makes correction harder and more error-prone.

Check: What makes a commit small, focused, and complete, and why does each quality matter?

Section 7: Writing Good Commit Messages

A commit message is a short explanation of what a commit does and why, written for future readers, including your future self, collaborators, and reviewers, who need to understand the change without necessarily reading all its code. A good message typically has a brief summary line, stating what changed in imperative, present-tense language, such as fix discount calculation for empty lists, followed, for larger commits, by a longer explanation of why the change was needed, what problem it solves, and any relevant context, such as a bug report or a design decision. The summary should be concise enough to read in a log listing, where many commits are shown together, while the longer explanation, when present, provides the detail needed to understand the change's rationale, which code alone may not reveal, especially for fixes where the why is more important than the what.

Writing good messages requires thinking about the reader's needs, not just about your own convenience at commit time. A vague message, such as fixed stuff, or updated code, tells the reader nothing, forcing them to examine the diff to understand the change, which wastes time and, for large diffs, may still leave the rationale unclear. A specific message, such as handle empty task list in overview to avoid division error, tells the reader immediately what was fixed and why it mattered, even before they look at code. Specificity also helps when searching history: a reader looking for when a particular bug was fixed, or when a feature was added, can search messages for relevant terms, and will find a specific message quickly, while a vague message will be missed, even if its diff contains the relevant change. Messages are therefore searchable documentation, and their wording should include terms a future searcher would use.

Message style conventions vary by team and project, but common conventions include starting with a verb in imperative form, keeping the summary line under a reasonable length, such as about fifty characters, so that it displays well in log listings, and separating the summary from a longer body with a blank line, so that tools can display the summary alone where appropriate. If no convention exists, adopt a simple, consistent style for your own projects, such as imperative summaries, and apply it to every commit, so that your history is uniform and professional.

Finally, write messages at commit time, when the change and its rationale are fresh, rather than postponing them or writing them hastily to get the commit done. A message written later, from memory, is likely to be vague or inaccurate, because details fade quickly, while a message written immediately captures the reasoning that motivated the change, which is the most valuable part for future readers. If you find yourself struggling to write a message, it may indicate that the commit itself is unfocused, containing unrelated changes that cannot be summarised in one statement, in which case you should reconsider staging, splitting the commit into focused parts, each with its own clear message, rather than forcing a vague message onto a mixed commit. Good messages and good commits reinforce each other: focused commits are easy to describe, and the effort of describing a commit clearly reveals when it is not focused, prompting a split that improves history quality.

Check: Why is a specific commit message more useful than a vague one for future readers searching history?

Section 8: Viewing History and Logs

Viewing history, through a log command, shows the sequence of commits in a repository, with their messages, authors, timestamps, and identifiers, allowing you to understand how a project evolved, when a feature was added, when a bug was fixed, and who made each change. Log output can be brief, showing one line per commit, which is useful for scanning recent history, or detailed, showing full messages and diffs, which is useful for examining a specific commit in depth. Learning to use both views, brief for overview and detailed for investigation, helps you navigate history efficiently, rather than reading every commit's full diff, which would be slow for a large history, or relying only on brief summaries, which may omit the rationale you need for a particular change. History is a resource, and log commands are how you access it, so familiarity with their basic options is essential.

History viewing is particularly useful for debugging, because it lets you find when a bug was introduced, by comparing the project's behaviour at different commits, or by searching messages for related changes. If a feature worked last week but fails now, checking the log for commits since last week narrows the changes that could have caused the failure, and examining those commits' diffs may reveal the faulty change directly. That technique, sometimes called bisecting when done systematically with automated testing, is powerful, but even informal log browsing, looking at recent commits and their messages, often points at the culprit, because the faulty change is usually recent and its message describes a related area. History therefore serves as a debugging tool, not just as a record, and developers who consult history regularly debug faster, because they can trace a problem to its introduction, rather than searching the current code blindly.

Log viewing also helps with understanding a codebase you did not write, or that you have not worked on for a while. That file history is often more informative than the file's current code alone, because it shows the decisions and trade-offs that shaped it, including fixes for past bugs that explain why certain checks exist, or refactorings that explain why code is structured as it is. A new team member who reads file history, alongside code, will understand the project more deeply and more quickly than one who reads code alone, because history provides context that code, which shows only the current state, cannot provide.

Finally, history viewing should be done with an awareness of privacy and sensitivity, because history may contain information that was committed accidentally, such as secrets or personal data, which remain in history even if later removed from the working copy, unless history is rewritten, which is disruptive and should be done carefully. When browsing history, especially in a shared project, be mindful of such information, do not share it unnecessarily, and report accidental inclusions to maintainers so they can be addressed appropriately, through history rewriting or other measures, following the project's policies. History is a powerful record, but it is also a permanent one, in the sense that committed information persists, and that permanence calls for care both when committing, to avoid including sensitive data, and when viewing, to handle any sensitive data that history may contain responsibly.

Check: How can viewing commit history help you find when a bug was introduced?

Section 9: Seeing Differences with Diff

Diff is the tool for seeing exactly what changed between two versions of a file, or between your working copy and the last commit, or between staged and unstaged changes, by showing lines added, removed, or modified, usually with context lines around them. Reading a diff is a fundamental version control skill, because it lets you review your own changes before committing, understand others' changes during review, and investigate history by comparing versions. A diff is presented line by line, with markers indicating additions, deletions, and unchanged context, and learning to read that format, though it may seem cryptic at first, quickly becomes natural, because it mirrors how code changes are actually structured: a few lines changed, surrounded by unchanged code that provides context. Practise reading diffs for small changes first, where the format is clear, before tackling large diffs, where it becomes essential to focus on the changed lines and to skim context.

Diff before committing is a quality check that should be part of every commit workflow. After staging, review the staged diff to confirm it contains exactly what you intend to commit, and nothing else, such as debugging prints, commented-out code, or unrelated changes that crept in during a session. After modifying files but before staging, review the unstaged diff to decide what to stage, especially if a file contains changes for multiple purposes that should be committed separately. That review, done carefully, catches mistakes that status alone cannot, because status tells you a file changed, but diff tells you how it changed, and whether that how matches your intention. A commit made without reviewing its diff is a commit made blind, and it risks including unintended changes that will pollute history and confuse future readers, while a commit made after diff review is a commit made with evidence that it is correct.

Diff is also essential for code review, where a reviewer examines another developer's changes by reading their diff, rather than by reading the entire files, because the diff highlights exactly what is new or changed, focusing attention on the relevant parts. That is another reason to make small, focused commits: they produce small, focused diffs that are easier to review, leading to better feedback and fewer bugs slipping through review. When you submit changes for review, ensure the diff is clean, with no unrelated changes, no debugging leftovers, and a clear message, so that reviewers can focus on the substance of the change, not on cleaning it up.

Finally, diff can compare any two versions, not just working copy versus last commit, such as two commits, a commit versus its parent, or two branches, which is useful for understanding how a feature evolved, or for checking what changed between releases. That flexibility makes diff a general comparison tool for history, not just a pre-commit check. Learn the basic diff commands for your version control tool, including how to diff working copy, staged changes, and commits, and practise reading diffs regularly, until the format feels familiar. Reading diffs well is a skill that improves with practice, and it underpins many other version control skills, from staging and committing to reviewing and resolving conflicts, all of which depend on understanding exactly what changed, line by line.

Check: Why should you review a diff before committing, rather than relying only on status?

Section 10: Undoing Unstaged Changes

Undoing unstaged changes means discarding modifications you made in the working copy but have not yet staged or committed, returning those files to their last committed state. That operation is useful when an experiment goes wrong, when you made changes you no longer want, or when you accidentally modified a file, and you want a clean slate for that file without affecting other files or committed history. Undoing is a destructive operation, in the sense that discarded changes cannot be recovered through version control, because they were never recorded, so it should be done deliberately, after confirming that the changes are truly unwanted, and preferably after checking the diff to see exactly what will be discarded, so that you do not lose work you meant to keep. That caution, checking diff before discarding, is a habit that prevents the distressing loss of hours of work through a hasty undo.

Undoing unstaged changes differs from undoing staged changes and from undoing commits, and understanding those distinctions prevents using the wrong undo for a situation. Unstaged undo affects only working copy modifications that have not been staged, leaving staged changes and committed history untouched. Staged undo, discussed next, moves changes from staged back to unstaged, without discarding them, allowing you to restage differently or to discard them separately. Commit undo, discussed later, affects history itself, by reverting or resetting commits, which is more consequential, especially if commits have been shared. Using the correct undo for the state your changes are in, unstaged, staged, or committed, ensures you discard or preserve exactly what you intend, without unintended side effects on history or on other changes. When in doubt, check status first, to see which state your changes are in, and then choose the corresponding undo.

A safe way to undo unstaged changes is to do so file by file, rather than discarding all changes at once, especially when your working copy contains changes for multiple purposes, some wanted and some not. For a single file whose changes are entirely unwanted, discarding that file's changes alone is straightforward and safe, provided you have confirmed, via diff, that the file contains no wanted changes mixed with unwanted ones, in which case finer undo, or manual editing, may be needed to preserve the wanted parts.

Finally, remember that undoing unstaged changes is not a substitute for committing frequently. If you commit often, with small, focused commits, then the amount of uncommitted work at any time is small, and discarding it, if needed, loses little, while the committed history preserves your progress. If you work for a long time without committing, then discarding unstaged changes could lose a great deal of work, making undo a high-stakes operation that you will hesitate to use, even when it would be helpful. Committing frequently, as discussed later, keeps uncommitted work small, making undo safe and useful, rather than frightening. Undo is a tool for cleaning up small, unwanted changes, not for recovering from a large, uncommitted mess, and its usefulness depends on the habit of committing regularly, which keeps the working copy's uncommitted changes manageable and discardable when necessary.

Check: Why is discarding unstaged changes irreversible, and what should you check before doing it?

Section 11: Unstaging Changes

Unstaging moves changes from the staging area back to unstaged status, without discarding them from the working copy, allowing you to reconsider what should be included in the next commit. Unstaging is useful when you staged a file by mistake, or staged too much, such as staging a file that contains changes for two different purposes, and you want to restage only part of it, or stage it in a later commit. Unlike discarding, unstaging is safe, because it does not delete work; it merely changes its staging status, so that the next commit will not include it, while the working copy retains the changes for future staging. That safety makes unstaging a routine adjustment, not a destructive operation, and it encourages careful staging, because mistakes in staging can be corrected easily, without losing work.

Unstaging is often used as part of refining a commit before making it. You stage files, review the staged diff, notice that it includes an unintended change, unstage that file or that part, and then commit the remaining staged changes, which now match your intention. That refine-then-commit workflow, staging, reviewing, unstaging if needed, and then committing, produces cleaner commits than staging everything at once and committing without review, because it gives you a chance to correct staging mistakes before they become history. Practise that workflow, even for small commits, so that it becomes automatic, and so that your commits consistently contain exactly what you intend, rather than whatever happened to be staged when you committed. Precision in staging is a hallmark of careful version control use, and unstaging is the tool that makes precision achievable without fear, because staging errors are easily fixed.

Unstaging also helps when you want to split a large set of changes into multiple commits. That splitting, done through repeated staging and committing, turns a messy session into a clean history, where each commit tells one story, as discussed earlier. Without unstaging and selective staging, splitting would be difficult, because you would need to manually separate changes, or commit them all together in a mixed commit that is hard to review. Staging and unstaging together give you fine control over commit contents, which is essential for maintaining a high-quality history, especially in larger projects where a single session may involve several distinct changes.

Finally, understand that unstaging does not affect the working copy's file contents, only their staging status. After unstaging, the files in your working copy are unchanged, still containing all your modifications, but version control now considers those modifications unstaged, meaning they will not be included in the next commit unless you stage them again. That distinction, contents versus staging status, can confuse beginners, who may expect unstaging to revert file contents, when it actually leaves them intact. Checking status after unstaging confirms the new state: files previously listed as staged now appear as modified but unstaged, while their contents remain as you left them. That confirmation, via status, helps you build a mental model of staging as a separate layer between working copy and history, a layer you control explicitly, through staging and unstaging, to craft exactly the commits you want.

Check: How does unstaging differ from discarding changes, and why is it safe?

Section 12: Amending the Last Commit

Amending the last commit means modifying the most recent commit, rather than creating a new one, by adding staged changes to it, or by changing its message, or both, while keeping it as a single point in history. Amending is useful for correcting small mistakes immediately after committing, such as forgetting to include a file, including a debugging print you meant to remove, or writing a commit message with a typo or a vague description. Instead of creating a second commit that fixes the first, which would clutter history with a fix-up commit for a mistake that was never shared, amending lets you correct the last commit directly, so that history shows a single, clean commit, as if the mistake had never been made. That cleanliness is valuable, provided the commit has not yet been shared, because amending rewrites history, even if only for the last commit, and rewriting shared history can disrupt collaborators who have already based work on the original commit.

Amending should therefore be used only for local, unshared commits, and only promptly, before sharing. If you have already pushed a commit to a remote, or shared it with collaborators in another way, amending it and then sharing the amended version will cause divergence, where your history and collaborators' histories differ for the same commit, requiring them to reconcile, which is confusing and error-prone. In that case, it is better to make a new commit that corrects the mistake, with a clear message, rather than amending, because a new commit preserves shared history and adds a correction on top of it, which collaborators can integrate normally. The rule is simple: amend local commits freely, correct shared commits with new commits, and never rewrite history that others may have already used, without coordinating with them first. That rule protects collaboration, even at the cost of a slightly less clean history for shared mistakes, which is a worthwhile trade-off.

To amend, you stage the additional changes you want to include, or make no staged changes if you only want to change the message, and then invoke the amend operation, which will either keep the existing message, if you do not change it, or let you edit it, if you do. After amending, verify the result by checking the log, which should still show a single last commit, now with the corrected contents or message, and by checking status, which should be clean if you amended all intended changes. If you find yourself amending a commit repeatedly, or amending a commit from much earlier work, it may indicate that the commit's scope was unclear, or that work should have been committed separately, and a new commit would be more appropriate and clearer. Amending is a correction tool, not a general editing tool, and its value lies in fixing immediate, small mistakes cleanly, before sharing. Overuse, especially for large changes, can make history confusing, because a commit's contents may change significantly after it was first created, without a record of that change, which defeats the purpose of history as a reliable record. Amend promptly, amend small mistakes, amend only local commits, and verify afterwards, and amending will improve your history without undermining its reliability.

Check: When is amending a commit appropriate, and when should you make a new correction commit instead?

Section 13: Branches: Working in Parallel

A branch is a parallel line of development, diverging from a main line at a certain point, allowing you to work on a feature, a fix, or an experiment, without affecting the main line until you are ready to integrate your work back into it. Branches are essential for managing parallel work, because they let multiple developers, or a single developer working on multiple tasks, progress independently, without interfering with each other or with stable code. For example, you might work on a new feature on one branch, while fixing a bug on another branch, and keeping the main line stable for release, all at the same time, switching between branches as needed, with version control tracking each branch's changes separately. That parallelism, managed through branches, is far more effective than trying to juggle multiple tasks in a single working copy, where changes for different purposes become mixed and hard to separate.

Branches are lightweight in modern version control, meaning creating a branch is quick and does not duplicate the project's files, but merely creates a pointer to a commit, from which new commits will diverge. That lightness encourages using branches freely, for even small tasks, because the cost of creating one is negligible, while the benefit, isolation of work, is significant. A branch for a small bug fix, a branch for a documentation update, and a branch for an experiment can all coexist, each with its own history, and each can be integrated or discarded independently, without affecting the others. That freedom to branch for any purpose, and to discard a branch if its work proves unwanted, makes branches a safe space for experimentation, similar to how commits make experimentation safe by allowing reversion, but at a larger scale, for whole lines of work rather than single snapshots.

Switching branches updates your working copy to match the branch's latest commit, which means any uncommitted changes in your current branch may interfere with the switch, if they conflict with the target branch's state, or may be carried over, if they do not conflict, depending on the tool's behaviour. To switch cleanly, commit your current work, or stash it, as discussed later, before switching, so that your working copy is clean and the switch can proceed without ambiguity. Checking status before switching, and ensuring it is clean or intentionally stashed, is a habit that makes branch switching smooth and predictable.

Finally, branches have names, and naming them clearly helps you and collaborators understand what each branch is for, without needing to examine its commits. A branch name might include a task type, such as feature, fix, or docs, plus a brief description, such as feature/add-task-search, which tells a reader at a glance that the branch adds task search functionality. Consistent naming conventions, agreed within a team, make branch lists readable, especially when many branches exist, and they help with automation and review, where branch names may be used to trigger checks or to indicate a branch's purpose. Choose branch names that are descriptive, concise, and consistent, avoid vague names like test or temp, which tell nothing about the branch's purpose, and delete branches after they are integrated, to keep the branch list tidy and focused on active work, rather than cluttered with completed branches that are no longer needed.

Check: How does working on a branch isolate a feature or fix from the main line of development?

Section 14: Creating and Switching Branches

Creating a branch starts from your current commit, which becomes the branch's starting point, and new commits on the branch will diverge from that point, forming a separate history that can later be merged back, or discarded, independently of the main line. Creating a branch does not automatically switch to it in all tools; some tools create and switch in one step, while others create only, requiring a separate switch step, so check your tool's behaviour and verify, via status or branch listing, which branch you are currently on after creating one. That verification prevents the common mistake of creating a branch for a feature, but then committing the feature's work to the main line because you forgot to switch, which defeats the branch's purpose and mixes feature work into stable code. Always confirm your current branch before starting work, especially after creating a new branch, by checking the branch indicator in status or in your shell prompt, if available.

Switching branches, as discussed earlier, updates the working copy to the target branch's state, and it should be done with a clean working copy, or with uncommitted changes intentionally stashed, to avoid carrying changes between branches unintentionally. When you switch, files that differ between branches will be updated to match the target, files that exist only on one branch may appear or disappear, and files that are identical on both branches will remain unchanged, which is normal and expected, though it can be surprising at first to see files appear or vanish as you switch. Understanding that switching is a view change, not a deletion, prevents panic when files change as you switch, and it helps you trust branches as safe parallel workspaces.

Branch listing shows all branches in a repository, with an indicator for the current branch, and it is useful for keeping track of active work, for finding a branch you created earlier, and for cleaning up branches that are no longer needed. A branch list that is cluttered with old, completed, or experimental branches is hard to navigate, and it can lead to confusion about which branches are active and which are stale, so periodic cleanup, deleting branches that have been merged and are no longer needed, is good hygiene, similar to cleaning up old environments. Deleting a branch does not delete its commits if they have been merged, because merged commits remain in the main history, but deleting an unmerged branch will discard its unique commits, so verify a branch is merged, or that its work is truly unwanted, before deleting it.

Finally, practise branching on a small project, where the stakes are low, before using it in a team or on a large project, because branching concepts, though simple in theory, become clearer through hands-on experience. Create a branch, make a commit on it, switch back to the main line, notice that the branch's commit is not present there, switch back to the branch, notice that it reappears, and then merge or discard the branch, observing how history changes in each case. Branching is a skill that rewards practice, and a small, personal project is the ideal place to practise, because mistakes there are easily corrected and do not affect collaborators, while the lessons learned transfer directly to team work, where branching is essential.

Check: Why should you verify which branch you are on after creating a new branch, before starting work?

Section 15: Merging Branches

Merging combines the changes from one branch into another, typically integrating a feature or fix branch back into the main line, after its work is complete and reviewed. Merging works by finding the common ancestor of the two branches, meaning the commit from which they diverged, comparing each branch's changes since that ancestor, and combining them into a new commit on the target branch, which has both branches' histories as its predecessors. Understanding merging as combining changes since a common ancestor, rather than as simply copying files, helps you predict when merging will be easy and when it will require manual resolution.

Before merging, ensure the branch to be merged is complete, tested, and reviewed, because merging integrates its changes into the target branch, where they will affect everyone working from that branch, and merging incomplete or broken work can disrupt the target. That pre-merge check, running tests on the branch, reviewing its diff, and confirming its purpose is complete, is similar to the pre-commit check, but at branch scale, and it is especially important for shared branches, where a bad merge can block a team. Also ensure your working copy is clean, or at least that you understand its state, before merging, because merging with uncommitted changes can complicate the result, by mixing uncommitted work with merged changes, making it hard to tell which changes came from the merge and which were already present. A clean working copy before merging makes the merge's result clear and its verification straightforward, by checking status and diff after merging to confirm the integrated state matches expectations.

After merging, verify the result by running tests, checking status, and reviewing the merge commit, to confirm that the combined code works and that no changes were lost or incorrectly combined. Merging, even when automatic, can produce a combined state that neither branch had alone, and that combined state may have integration bugs, such as two branches that each added a function with the same name, or that each changed a shared configuration in incompatible ways, which automatic merging may combine without error, but which will fail when run. If tests fail after merging, investigate the integration, not just the individual branches, because the problem may be in how their changes interact, rather than in either change alone, and fixing it may require a new commit on the target branch that reconciles the interaction.

Finally, after a successful merge, the source branch is usually no longer needed, and it can be deleted, to keep the branch list tidy, as discussed earlier, provided its commits are now part of the target's history through the merge. Keeping merged branches around clutters the list and can cause confusion about which branches are active, so deletion after merging is standard practice, though some teams keep branches for a short period after merging, as a safety measure, in case the merge needs to be reverted, which is a judgement call based on the project's risk and the team's conventions. Merging is the culmination of branch work, turning parallel development into integrated progress, and doing it carefully, with pre-merge checks, clean state, post-merge testing, and cleanup, ensures that integration strengthens the project rather than destabilising it.

Check: How does merging combine changes from two branches, and why must you test after merging even if it was automatic?

Section 16: Merge Conflicts

A merge conflict occurs when two branches changed the same part of the same file in different ways since their common ancestor, so that version control cannot decide automatically which change to keep, because both changes are plausible and they overlap. Conflicts are a normal part of collaboration, not a sign of failure, because they arise whenever parallel work touches the same area, which is inevitable in a team, especially for files that many people edit, such as configuration files or shared modules. A conflict does not mean the merge failed irreparably; it means the merge paused, asking a human to decide how to combine the overlapping changes, because only a human understands the intention behind each change and can reconcile them correctly. That human decision is an essential part of merging, and learning to resolve conflicts calmly and systematically is a key version control skill.

When a conflict occurs, version control marks the conflicting files and inserts markers into them, showing the overlapping sections from each branch, with labels indicating which branch each section came from, and with a section for the common ancestor's version, in some tools, to help you understand what each branch changed. Those markers, though they may look messy in the file, are temporary and informative: they show you exactly where the branches disagree, and what each branch's version says, so that you can edit the file to a reconciled version that incorporates the intention of both changes, or chooses one over the other where they are truly incompatible. Do not be intimidated by markers; read them as a comparison, understand each side's change, and then edit the file to remove markers and to produce the correct combined content, which may be one side, the other side, a combination of both, or a new version that neither side had alone, if both changes need to be integrated in a novel way.

Resolving a conflict involves more than editing the file; it involves understanding the changes, testing the result, and then marking the conflict as resolved by staging the reconciled file, which tells version control that you have decided how to combine the changes and that merging can proceed. Skipping the testing step after resolving, and committing a reconciled file that has not been tested, risks introducing a bug that neither branch had, because the reconciliation itself may be flawed, such as combining two changes in a way that is syntactically correct but logically wrong, which only tests or careful review will catch.

Finally, conflicts can be minimised, though not eliminated, by good practices: working on separate branches for separate tasks, merging frequently so that branches do not diverge too far, communicating with teammates about who is editing which files, and making small, focused commits that are easier to merge than large, sweeping changes. When conflicts do occur, treat them as a normal, manageable part of collaboration, not as a crisis, resolve them systematically by understanding each side, editing to a reconciled version, testing, and committing, and you will find that conflicts, though initially daunting, become a routine part of integrating parallel work, and a valuable opportunity to understand how different changes interact.

Check: What causes a merge conflict, and what does version control do when one occurs?

Section 17: Resolving Conflicts Step by Step

Resolving a conflict step by step begins with understanding the conflict's context, not with immediately editing the file. First, check status to see which files are conflicted, and open each conflicted file to read its markers, identifying the sections from each branch and, where available, the common ancestor's version. For each conflicted section, ask: what did the first branch change, what did the second branch change, and why did each branch make its change, based on its commits' messages and diffs? That understanding, gained by examining history for the conflicting lines, is essential, because resolving without understanding risks choosing the wrong side, or combining changes in a way that breaks one branch's intention, even if the file looks syntactically correct. Take the time to understand, especially for conflicts in logic, where the correct reconciliation may not be obvious from the text alone, but requires knowing what each change was trying to achieve.

Next, edit the file to a reconciled version, removing all conflict markers, and ensuring the resulting code is syntactically correct and logically coherent. The reconciled version may keep one side's changes, if the other side's changes are obsolete or were superseded, it may keep both sides' changes, if they address different aspects and can coexist, with appropriate ordering or integration, or it may be a new version that synthesises both, if neither side alone is sufficient. When combining both sides, be careful about duplication, ordering, and interaction: two branches that each added a similar function, or each changed the same variable, may need deduplication or renaming, and two branches that each changed a condition may need a combined condition that satisfies both intentions. Edit thoughtfully, not mechanically, by choosing the version that best serves the project's current needs, informed by both branches' intentions, rather than by simply picking the side that looks simpler or that you wrote yourself, which is a bias to guard against in conflict resolution.

After editing, test the reconciled file, by running relevant tests, or at least by checking that the file parses and that the affected functionality works for cases from both branches. Testing is crucial, because a reconciled file can look correct but behave incorrectly, if the combination introduced a subtle bug, such as a variable used before it is defined, a function called with wrong arguments due to combined changes, or a logic error from merging two conditions incorrectly. Testing after resolution, before marking the conflict as resolved, ensures that the merge commit will record a working state, not a broken reconciliation that will need a follow-up fix, which would be embarrassing and confusing for collaborators who pull the merge expecting it to work.

Finally, stage the resolved file to mark the conflict as resolved, repeat the process for any other conflicted files, and then complete the merge with a commit, after verifying status shows no remaining conflicts and that the staged changes include all resolutions. Step-by-step resolution, understanding, editing, testing, staging, and committing, with verification at each stage, turns a daunting conflict into a manageable, systematic task, and it produces a merge that is correct, tested, and documented, rather than a hasty resolution that introduces new problems.

Check: What steps should you follow, in order, to resolve a merge conflict?

Section 18: Ignoring Files with gitignore

An ignore file tells version control which files and folders to exclude from tracking, by listing patterns that match those files, such as a folder name, a file extension, or a specific file. Ignored files will not appear as untracked in status, will not be added by bulk add commands that add all untracked files, and will not be committed, unless explicitly forced, which protects against accidental inclusion of files that should not be in the repository. Learning to write ignore patterns is therefore a basic skill for keeping a repository clean and focused on source files.

For Python projects, a standard set of ignore patterns covers virtual environments, bytecode caches, distribution folders, test caches, editor files, and operating system files, among others. Those patterns are widely shared in templates and guides, and starting from such a template, adapted to your project's specific needs, is more reliable than writing an ignore file from scratch, where you might forget a common pattern and accidentally commit generated files. A template ignore file, reviewed and adapted, ensures that common generated files are excluded from the start, while project-specific patterns, such as a local data folder or a personal configuration file, can be added as the project grows and new kinds of generated or local files appear. Review the ignore file periodically, as new tools are adopted, because a new tool may generate files that the current ignore patterns do not cover, and those files will then appear as untracked, signalling that the ignore file needs updating.

Ignore files themselves should be tracked, meaning the ignore file is committed to the repository, so that all collaborators share the same ignore rules, and so that a new clone of the project automatically ignores the right files, without each collaborator needing to set up ignores manually. Committing the ignore file ensures consistency, and updating it, when new patterns are needed, should be done through a normal commit, with a message explaining why the new pattern was added, so that the history of ignore decisions is visible, just like other project decisions.

Finally, understand that ignoring a file does not delete it, nor does it remove it from history if it was already tracked before being ignored. Ignoring only prevents untracked files from being added; a file that is already tracked will continue to be tracked even if a pattern matching it is added to the ignore file, until it is explicitly removed from tracking, which is a separate operation that must be done carefully, especially if the file contains sensitive data, because removing it from future commits does not remove it from past history. That distinction, ignore for untracked versus remove for tracked, is important for correcting mistakes, such as accidentally committing an environment folder or a secret file, where simply adding an ignore pattern is insufficient, and more involved history correction is needed, ideally with guidance from the project's maintainers and with awareness of the implications for shared history. Ignoring correctly from the start prevents most such mistakes, which is why early ignore setup is emphasised throughout this chapter.

Check: What does an ignore file do, and why should the ignore file itself be tracked in the repository?

Section 19: Python-Specific Ignores

Python projects generate several kinds of files that should be ignored, beyond the virtual environment folder, and knowing them helps you write a complete ignore file that keeps a repository focused on source. Bytecode files, with a common extension, are generated when Python imports modules, to speed up future imports, and they are stored in cache folders alongside source files. Those files are recreatable, by running Python again, and they are specific to the Python version and platform that generated them, so tracking them would bloat the repository with files that are not source, that change frequently, and that may not work on another system. Ignoring bytecode files and their cache folders is therefore standard, and it prevents status from being cluttered with generated files every time you run code, which would otherwise make it hard to see genuine source changes among generated noise.

Distribution and build folders, created when packaging a Python project for distribution, as covered in a later chapter, contain built copies of the project, which are generated from source and should not be tracked, because they can be rebuilt from source at any time, and because tracking them would duplicate source in built form, causing confusion about which copy is authoritative. Similarly, test and coverage output folders, created when running tests or measuring coverage, contain reports and data generated during testing, which are useful locally but should not be tracked, because they change with every test run and because they can be regenerated by running tests again. Ignoring those output folders keeps the repository focused on code and tests, not on their outputs, which are transient and machine-specific, and it prevents large, frequently changing files from polluting history.

Editor and tool configuration files that are personal, such as editor workspace settings that contain local paths, or tool caches that store personal preferences, should also be ignored, while shared configuration, such as formatting or linting settings that the whole team should follow, should be tracked, because it defines the project's standards. Distinguishing personal from shared configuration requires judgement: if a file contains paths specific to your computer, or preferences that only you use, ignore it; if it contains rules that all collaborators should follow, track it, and document that it is shared. When in doubt, ask whether a collaborator would benefit from the file, or whether it would confuse them with your personal settings, and decide accordingly.

Finally, Python projects often involve data files, such as small sample data used in tests, which should be tracked if they are part of the project's tests or examples, and large or personal data files, which should be ignored if they are not part of the code, are too large for version control, or contain sensitive information. That distinction, sample data versus real or large data, is important, because tracking large data files can bloat a repository and slow down cloning, while ignoring sample data needed for tests can cause tests to fail for collaborators who do not have the data. Python-specific ignores, covering bytecode, builds, test outputs, personal tooling, and large data, together with the virtual environment, form a comprehensive ignore strategy that keeps a Python repository clean, fast, and focused on source.

Check: Why should bytecode files and test output folders be ignored, even though they are generated by running code?

Section 20: Remotes and Sharing

A remote is a version of a repository hosted elsewhere, such as on a server or a hosting service, that your local repository can communicate with, to share commits, to back up history, and to collaborate with others. Adding a remote connects your local repository to that hosted copy, by recording its location under a short name, conventionally origin for the main remote, so that you can refer to it easily in push and pull commands, without typing its full location each time. A remote is not a different kind of repository; it is simply another copy, hosted remotely, that holds the same kind of history as your local copy, and the relationship between local and remote is one of synchronisation, where you push local commits to the remote to share them, and pull remote commits to your local copy to receive others' work. Understanding remotes as synchronised copies, rather than as a central authority that controls your local work, helps you use them confidently, because your local repository remains fully functional even without a remote, and a remote is an addition for sharing, not a requirement for version control itself.

Creating a remote usually involves creating an empty repository on a hosting service, through its web interface or command-line tool, and then adding that repository's location as a remote to your local repository, followed by pushing your local history to it, which populates the remote with your commits. That initial push establishes the remote as a mirror of your local history, and subsequent pushes will update it incrementally, with only new commits, rather than resending all history each time. Cloning is therefore the standard way to obtain an existing project, while adding a remote and pushing is the standard way to share a new project, and both workflows result in a local repository connected to a remote for future synchronisation.

Remote branches are branches that exist on the remote, tracked locally through remote-tracking references, which show the remote branches' state as of the last time you communicated with the remote, through fetching or pulling. Fetching updates remote-tracking references without changing your local branches or working copy, which is a safe way to see what others have done, while pulling fetches and then merges or integrates those changes into your current branch, which changes your local state and should be done with a clean working copy, or with awareness of how it will affect uncommitted work. Distinguishing fetch, which only updates references, from pull, which also integrates, is important for controlling when your local work changes due to remote updates, rather than being surprised by an automatic merge during a pull.

Finally, sharing through remotes requires care about what you push, because pushed history becomes shared history, which should not be rewritten without coordination, as discussed for amending. A clean, reviewed push, with focused commits and no unintended files, reflects well on you as a collaborator and makes review and integration easier for others, while a messy push, with mixed commits and unintended files, creates work for others to untangle. Push thoughtfully, pull regularly to stay up to date, and treat remotes as a shared, synchronised record that the whole team relies on.

Check: What is the difference between fetching from a remote and pulling from it?

Section 21: Cloning a Project

Cloning creates a local copy of a remote repository, including its full history, its branches, and its remote configuration, so that you can work on the project locally, even offline, and synchronise with the remote when you are ready to share or receive changes. Cloning is the standard way to start working on an existing project, as opposed to initialising a new repository, which is for new projects, and it is done by specifying the remote repository's location, after which version control downloads its history and sets up a working copy from its main branch, ready for you to edit. That downloaded history is complete, meaning you can browse past commits, switch branches, and work offline, without needing constant connection to the remote, which is a significant advantage over systems that require a connection for every operation. Cloning gives you a full, independent copy, not just a snapshot of current files, and that fullness is what makes local version control powerful even for shared projects.

After cloning, your local repository is configured with a remote, conventionally named origin, pointing to the source repository, and with remote-tracking branches for the remote's branches, so that you can see what branches exist remotely and fetch updates from them. Your working copy will be on the project's main branch, or on a branch specified during cloning, and you can then create your own branches for features or fixes, as discussed earlier, without affecting the remote until you push. That setup, clone then branch, is the standard collaboration workflow for a new contributor: obtain the project by cloning, create a branch for your task, work and commit on that branch, and then push the branch and request its integration, through a pull request or a similar mechanism, discussed later. Following that workflow, rather than working directly on the main branch, keeps your work isolated and reviewable, and it prevents accidental changes to shared stable code.

Cloning a large repository can take time and disk space, because it downloads full history, including all files at all versions, though modern tools offer options to clone only recent history or only certain branches, which can speed up cloning for large projects where full history is not needed immediately. As a learner, start with full clones, understand what they provide, and consider partial options only when a project's size makes full cloning impractical, which is rare for the projects in this book.

Finally, after cloning, verify the setup by checking status, which should show a clean working copy, by checking the log, which should show the project's history, and by running the project's tests or setup instructions, to confirm that the cloned project works in your environment, with its requirements installed as per its documentation. A cloned project that has not had its environment set up will fail to run, not because cloning failed, but because dependencies are missing, and distinguishing those two causes, clone versus environment, saves troubleshooting time. Clone, set up the environment, verify by running tests, and you have a working local copy ready for development, which is the goal of cloning as a starting point for collaboration.

Check: What does cloning give you that simply downloading current files does not?

Section 22: Pushing and Pulling

Pushing sends your local commits to a remote repository, updating the remote's branches to include your work, so that collaborators can obtain it by pulling or cloning. Pushing is a sharing act, and it should be done after verifying your commits, as discussed for remotes, because pushed commits become part of shared history. A push typically specifies which local branch to push and which remote branch to update, and for a first push of a new branch, it also sets up tracking, so that future pushes and pulls for that branch can be done without specifying branches explicitly, by remembering the association between local and remote branches. That tracking association, once set up, simplifies future synchronisation, because you can push and pull with simple commands that use the tracked relationship, rather than specifying branches each time. Understanding tracking helps you interpret push and pull messages, which often refer to tracked branches and to whether your local branch is ahead of, behind, or diverged from its remote counterpart.

Pulling is the reverse: it brings commits from a remote branch into your local branch, updating your local history and working copy to include others' work. Pulling, in its common form, fetches remote changes and then merges them into your current branch, which may result in a merge commit, if your local branch and the remote branch have diverged, or in a fast-forward update, if your local branch has no unique commits and can simply be advanced to the remote's state. Both are normal, and neither is inherently better, though teams may prefer one style over the other for readability, and they may configure pulling to use a different integration method, such as rebasing, which is beyond the scope of this introductory chapter but which you may encounter in team settings, where it is used to keep history linear by replaying local commits on top of remote changes.

Before pulling, ensure your working copy is clean, or understand how pulling will interact with uncommitted changes, because pulling with uncommitted changes that conflict with remote changes can complicate the merge, or can cause the pull to fail until you commit or stash your changes. Pulling regularly, rather than letting your local branch fall far behind the remote, reduces the size and complexity of each pull, because fewer remote changes need to be integrated at once, and it keeps you aware of others' work as it happens, rather than being surprised by a large batch of changes later.

Pushing and pulling together form the synchronisation loop for collaboration: work locally, commit, pull to receive others' work and integrate it, resolve any conflicts, test, and push to share your integrated work, repeating that loop as development proceeds. A common beginner mistake is to work for a long time without pulling, then to face a large, complex pull with many conflicts, when pulling frequently would have integrated changes incrementally, with smaller, easier conflicts. Pull often, push after integrating and testing, and treat synchronisation as a regular part of development, not as an occasional event before a deadline, and collaboration through version control will feel smooth rather than stressful.

Check: How do pushing and pulling together keep local and remote histories synchronised?

Section 23: Pull Requests and Code Review

A pull request, also called a merge request in some tools, is a proposal to integrate a branch's changes into another branch, usually the main branch, accompanied by a description of the changes, their purpose, and any relevant context, and it serves as the focal point for code review, discussion, and testing before integration. Unlike a direct push to the main branch, which integrates changes immediately, a pull request creates a review step, where collaborators can examine the diff, comment on specific lines, ask questions, suggest improvements, and approve or request changes, before the branch is merged. That review step is a quality gate, catching bugs, style issues, and design problems that the author overlooked, and it is also a learning opportunity, where reviewers share knowledge and authors learn from feedback. Pull requests are therefore central to team collaboration, not just a technical mechanism for merging, but a social process for ensuring code quality and shared understanding.

Creating a good pull request starts with a good branch, with focused commits, clear messages, and tests, as discussed earlier, because reviewers can only review effectively if the changes are understandable and testable. The pull request's description should summarise what the branch does, why it is needed, how it was tested, and any relevant context, such as related issues or decisions, so that reviewers can understand the change's purpose without needing to ask basic questions. A description that merely says changes, without explanation, forces reviewers to infer purpose from code alone, which is inefficient and error-prone, while a clear description lets reviewers focus on whether the implementation correctly achieves its stated purpose, which is the core of review. Include screenshots or examples where relevant, for changes that affect output or user interaction, though for a Python library or script, test results and example outputs are often more relevant than screenshots.

Reviewing a pull request involves reading its diff carefully, checking logic, edge cases, tests, style, and documentation, and providing feedback that is specific, constructive, and focused on the code, not on the author. That thoroughness, applied consistently, raises a project's overall quality, because every change is examined by at least one other person before integration, catching problems that automated tests alone might miss, such as unclear naming, missing edge cases, or design choices that conflict with the project's direction.

Finally, merging a pull request, after approval and after automated checks pass, integrates the branch into the target, usually with a merge commit that records the pull request's number and description, preserving the review context in history. After merging, the source branch is typically deleted, as discussed for branches generally, and the author should pull the updated target branch locally, to ensure their local copy includes the merged work, before starting new work. That post-merge pull prevents working from an outdated main branch, which could cause conflicts later. Pull requests, used well, with clear descriptions, thorough review, and clean merging, transform version control from a solitary history tool into a collaborative quality process, where code is not just recorded, but examined, discussed, and improved before it becomes part of the shared project, which is a hallmark of professional software development.

Check: How does a pull request add a review step beyond simply pushing to the main branch?

Section 24: Working with Issues

Issues are records in a hosting service's tracker for tasks, bugs, feature requests, questions, and discussions related to a project, and they complement version control by providing a place to plan, discuss, and track work that is not yet code, or that needs discussion before code is written. An issue typically has a title, a description, labels indicating its type or priority, comments where collaborators discuss it, and a status indicating whether it is open or closed, and it may be linked to pull requests that address it, so that the history of a change includes both the code, in commits, and the discussion, in issues, giving a complete picture of why a change was made. Using issues effectively, by writing clear titles and descriptions, by discussing approaches before coding, and by closing issues when their work is complete, helps a team coordinate, prioritise, and remember decisions, especially for larger projects where work cannot be held entirely in one person's head.

Writing a good issue, for a bug, involves describing the problem specifically: what happened, what was expected, how to reproduce it, with steps and data, and what environment it occurred in, similar to asking for debugging help effectively, as discussed in the debugging chapter. That specificity lets others reproduce and fix the bug without needing to ask clarifying questions, which saves time and leads to faster fixes. For a feature request, a good issue describes the need, the proposed solution, and the benefits, without prescribing implementation details prematurely, so that collaborators can discuss alternatives before committing to one approach. Clear issues, like clear commit messages, are documentation for future readers, explaining why work was done, not just what code resulted.

Linking issues to branches and pull requests creates traceability, where a pull request can reference the issue it addresses, often by number, so that readers can navigate from code to discussion and back, understanding both the implementation and its rationale. That linking is usually done by mentioning the issue number in a branch name, a commit message, or a pull request description, depending on the team's conventions, and it helps with project management, because closing a pull request can automatically close its linked issue, indicating the work is complete, without manual tracking. Traceability is especially valuable for bugs, where the issue records the original report and reproduction steps, and the pull request records the fix and its tests, together forming a complete record of the bug's lifecycle, from report to resolution, which is useful for future debugging if a similar bug recurs, because the team can revisit the original discussion and fix.

Finally, issues should be managed actively, not left to accumulate indefinitely, because a tracker full of stale, outdated issues becomes hard to use, with contributors unsure which issues are still relevant and which have been superseded. That maintenance, done by maintainers or by the team collectively, is part of project stewardship, similar to maintaining a test suite or a requirements file, where regular tidying prevents decay. Issues, used well, with clear writing, linking to code, and active maintenance, turn version control's code history into a broader project history, encompassing planning, discussion, and decisions, which together make a project understandable and maintainable over time.

Check: How does linking an issue to a pull request create traceability from a problem to its fix?

Section 25: Stashing Work Temporarily

Stashing saves uncommitted changes temporarily, without committing them, so that you can work on something else, such as switching branches or pulling updates, with a clean working copy, and then restore the stashed changes later, when you are ready to continue the original work. Stashing is useful when you are in the middle of a task, with uncommitted changes you do not want to commit yet, because they are incomplete, but you need to switch to another task urgently, such as fixing a bug on another branch, or pulling updates that conflict with your current changes. Instead of committing incomplete work just to switch, which would pollute history with a work-in-progress commit, or discarding your changes, which would lose work, stashing preserves them safely, in a stack of stashes, from which they can be restored, applied, or discarded later, as needed. That temporary preservation makes stashing a valuable tool for managing interruptions and context switches, which are common in real development.

Using stashing involves a few steps: stash the current changes, with an optional message describing what was stashed, so that you can identify it later if you have multiple stashes, switch branches or perform the urgent task with a clean working copy, and then, when you return to the original task, restore the stash, which reapplies its changes to the working copy, and, if successful, removes the stash from the stack, or keeps it, depending on the operation used, so that you can verify the restoration before discarding the stash. That verification step, checking status and diff after restoring, is important, because restoring a stash onto a working copy that has changed since stashing, such as after pulling updates, can cause conflicts, similar to merging, which must be resolved before continuing. Stashing, like merging, can produce conflicts when restored onto a different state, and those conflicts should be resolved systematically, as discussed earlier, rather than ignored.

Stashing should be used for short-term, temporary storage, not as a long-term way to save work, because stashes are not as visible or as durable as commits, they are not shared through remotes, and they can be forgotten, especially if you accumulate many stashes without messages, making it hard to remember what each contains. A stash that sits for weeks, while you work on other tasks, risks being forgotten, or becoming difficult to restore cleanly, because the code it was based on has changed significantly in the meantime, increasing the chance of conflicts. Use stashing for interruptions measured in hours or a day, not for work paused for weeks, and name stashes clearly, so that even a short-term stash can be identified if you return to it after a break.

Finally, list stashes periodically, and clean up old ones, by restoring or discarding them, so that the stash stack does not become cluttered with forgotten work. Regular cleanup, after completing the urgent task that prompted stashing, by restoring the stash and then committing its changes properly, turns a temporary stash into permanent, committed work, which is the goal. Stashing is a bridge between tasks, not a destination for work, and using it as a bridge, briefly and clearly, with restoration and cleanup as part of the workflow, makes context switching smooth and safe, without losing work or polluting history with incomplete commits.

Check: When should you stash changes instead of committing them, and when is a branch a better choice?

Section 26: Tags and Releases

Tags mark specific commits as important points in history, usually for releases, such as version 1.0, version 1.1, or a milestone, giving those commits a human-readable name that is easier to remember and refer to than a commit identifier. Tagging a release commit lets users, collaborators, and deployment tools refer to that version by name, such as checking out version 1.0 to run or to debug that specific release, without needing to know its commit identifier, and it provides a stable reference that does not move, unlike a branch, which advances as new commits are added. That stability is essential for releases, because a release must refer to a fixed set of code, not to a moving branch that will change over time, and tags provide that fixed reference, preserved in history, so that a release can always be recreated exactly from its tag, even years later, provided the repository's history is preserved.

Creating a tag is usually done on the commit that represents the release, after verifying that the commit passes tests and is ready to be released, and the tag may include a message describing the release, its changes, and any important notes, similar to a commit message but focused on the release as a whole, rather than on a single change. That tag message, or the release notes associated with it on a hosting service, should summarise the release's major changes, bug fixes, and any breaking changes, so that users deciding whether to upgrade can understand what the release contains and whether it affects them. Release notes are a form of documentation for users, distinct from commit messages, which are for developers, and writing clear release notes is a skill that improves with practice, focusing on user-visible changes and their impact, rather than on every minor commit, which would overwhelm users with detail they do not need.

Tags, like branches, can be shared through remotes, by pushing them explicitly, because tags are not pushed automatically with normal pushes in all tools, and a tag that exists only locally is not visible to collaborators until it is pushed. When pushing tags, verify that the tag points to the correct commit, and that its message is accurate, because a pushed tag, like a pushed commit, becomes shared history, and correcting a shared tag, by moving it to a different commit, can confuse collaborators who have already used the original tag, similar to rewriting shared commits. Tag carefully, verify before pushing, and treat pushed tags as stable references that should not be moved lightly.

Finally, releases on hosting services often build on tags, by associating a tag with downloadable files, such as a packaged version of the project, release notes, and other assets, creating a release page that users can visit to obtain a specific version. For a learner, understanding tags as stable release markers, distinct from branches as moving development lines, is sufficient, and practising tagging on a small project, by tagging a version after completing a feature, and then checking it out to verify it, builds familiarity with releases as a concept, preparing you for larger projects where releases are a formal process involving testing, documentation, and distribution, all anchored to a tag in version control history.

Check: How does a tag differ from a branch, and why is that difference important for releases?

Section 27: Reverting Changes Safely

Reverting means creating a new commit that undoes the changes of a previous commit, without deleting that previous commit from history, preserving the record of both the original change and its reversal. Reverting is the safe way to undo a shared commit, because it does not rewrite history, but adds to it, so that collaborators who have already based work on the original commit can integrate the revert normally, by pulling, without needing to reconcile rewritten history. That safety makes reverting the preferred undo method for shared work, in contrast to resetting or amending, which rewrite history and should only be used for local, unshared commits, as discussed earlier. Understanding revert as adding a counter-commit, rather than as deleting history, is key to using it correctly, because it explains why reverting does not erase the original commit from the log, but instead adds a new commit on top of it that reverses its effects, leaving a complete, honest record of what happened, including the mistake and its correction.

Reverting is useful when a commit, after being shared and possibly after other commits have built on it, turns out to be wrong, such as a feature that causes problems, a change that breaks compatibility, or a fix that was incorrect. In such cases, simply deleting the commit from history would be disruptive, because later commits may depend on it, and collaborators may have already integrated it, so a revert commit, which undoes its changes while preserving later history as much as possible, is a cleaner solution, though it may itself conflict if later commits modified the same code, requiring manual resolution similar to a merge conflict. Reverting is therefore not always automatic, but it is still safer than rewriting history, because its conflicts are visible and resolvable through normal merging, rather than through history rewriting that affects all collaborators.

To revert, you identify the commit to undo, by its message or identifier, and invoke the revert operation, which will attempt to create a new commit that reverses its changes, prompting you to resolve any conflicts that arise, and to confirm the revert commit's message, which should explain why the original change is being reverted, referencing the original commit and the reason, such as a bug it introduced or a decision to postpone the feature. That message is important, because future readers, seeing both the original commit and the revert, need to understand why the reversal happened, otherwise they may wonder whether the revert was a mistake, or may reintroduce the original change without realising it was deliberately reverted. Clear revert messages, stating the reason and linking to any relevant issue or discussion, preserve that understanding, and they prevent the cycle of a change being added, reverted, and re-added without awareness of its history.

Finally, reverting should be followed by testing, to confirm that the project works correctly after the revert, and that the revert did not break later work, especially if conflicts were resolved manually. Reverting safely, with a clear message, careful conflict resolution, and thorough testing, is a mature version control skill, showing that you understand history as a shared, append-only record, where mistakes are corrected by adding new history, not by erasing old history, which is a principle that protects collaboration and preserves trust in the project's record.

Check: Why is reverting safer than rewriting history for undoing a shared commit?

Section 28: Resetting with Caution

Resetting moves a branch's pointer to a different commit, effectively discarding commits after that point from the branch's history, or unstaging or discarding changes, depending on the reset mode, and it is a powerful but dangerous operation, because it can lose work if used carelessly, especially if the discarded commits have been shared or if uncommitted changes are discarded along with them. Resetting is useful for local, unshared work, such as discarding a series of experimental commits that proved unwanted, or undoing a mistaken commit before sharing it, where rewriting local history is safe because no one else has based work on it. In those local cases, resetting provides a clean way to rewrite history to a desired state, without leaving behind fix-up commits or revert commits that would clutter a history that has not yet been shared. That local usefulness, however, does not extend to shared history, where resetting would cause divergence and confusion for collaborators, as discussed for amending, and where reverting, not resetting, is the safe choice.

Reset modes differ in what they do to the working copy and staging area, in addition to moving the branch pointer, and understanding those modes is essential for using reset safely. That destructive mode should be used only when you are certain the discarded work is unwanted and unneeded, after checking status and diff, and preferably after backing up work you might want, such as by stashing or by creating a branch pointing to the current state before resetting, as a safety net.

Because resetting can lose work, a safe practice before any reset, especially a destructive one, is to create a backup reference, such as a branch or a tag pointing to the current state, so that discarded commits or changes can be recovered if you realise you made a mistake. That backup, created in seconds, provides a safety net that turns a potentially irreversible operation into a reversible one, because you can reset back to the backup if needed, or cherry-pick commits from it, which is a more advanced operation for copying specific commits between branches. Backing up before resetting is a habit that reflects a general principle for destructive operations: preserve a way back before destroying, whether through a branch, a stash, a copy, or a commit, so that mistakes are correctable.

Finally, if you have already reset and lost work, do not panic, but act quickly to check whether recovery is possible, through recent backups, through the tool's reflog or similar history of branch movements, which records where branches pointed previously, even after resetting, or through stashes or other branches that may contain the lost work. That uncertainty is why prevention, through backups and careful checking before resetting, is far better than recovery after the fact. Resetting, used cautiously, locally, with backups, and with a clear understanding of its modes, is a useful tool for cleaning up local history, but it should never be used casually on shared history, or destructively without a safety net, because its power to rewrite history is also its power to lose work.

Check: Why should you create a backup branch before a destructive reset, and why is resetting shared history dangerous?

Section 29: Small Commits, Often

Committing small and often is a habit that improves history quality, reduces risk, and makes collaboration easier, because each commit represents a single, understandable step, rather than a large, mixed bundle of changes accumulated over a long period. Small commits are easier to review, because reviewers can focus on a limited diff, easier to revert, because reverting a small commit affects only its specific change, rather than a bundle of unrelated changes, and easier to debug, because when a bug is introduced, history with small commits lets you pinpoint the exact commit that introduced it, by testing commits one by one, whereas history with large commits leaves you with a large change to search within, where the bug could be anywhere. That pinpointing, through small commits, is a powerful debugging aid, turning history into a diagnostic tool, as discussed earlier for log viewing, but only if commits are small enough that each one can be tested and understood individually.

Committing often also reduces the risk of losing work, because committed work is recorded in history and can be recovered, while uncommitted work exists only in the working copy, vulnerable to accidental deletion, to a computer failure, or to a mistaken undo operation. Committing often does not mean committing broken or incomplete work to a shared branch, which would disrupt collaborators, but committing working, focused steps to a local branch, or to a personal branch, where incomplete work is expected and can be refined before sharing through a pull request, as discussed earlier. Frequent local commits, refined before sharing, give you both safety and clean shared history.

Small, frequent commits also make merging easier, because they produce a history of incremental changes that can be integrated gradually, rather than a single large change that conflicts massively with others' work. When two developers work in parallel, each committing small steps and pulling frequently, their changes integrate in small pieces, with small, manageable conflicts, if any, while developers who work in isolation for weeks and then try to merge a huge change face large, complex conflicts that are difficult to resolve and that may introduce bugs. The rhythm, commit, pull, push, repeat, in small cycles, keeps histories close and integrations smooth, which is far more efficient than large, infrequent synchronisations, even though the latter may feel more productive in the moment, because it avoids the overhead of frequent commits, an overhead that is actually tiny compared with the cost of resolving a large merge later.

Finally, small, frequent commits require discipline, because it is tempting to keep working without committing, especially when you are in a flow state and do not want to interrupt it to write a message and run tests. That pause, though it interrupts flow briefly, actually supports flow in the long run, because it gives you a safe point to return to, a clear record of progress, and a moment to reflect on whether your current direction is correct, before proceeding further in a potentially wrong direction. Small commits, often, are therefore not just a version control technique, but a work habit that structures development into manageable, verifiable steps, improving both the code and the history that records it, and making you a more reliable collaborator and a more effective debugger of your own work.

Check: How do small, frequent commits make debugging and merging easier than large, infrequent ones?

Section 30: Reviewing Before You Commit

Reviewing before you commit means examining your staged changes, through diff and status, and checking them against the commit's purpose, its tests, and the project's standards, before creating the commit, so that the commit you record is clean, correct, and complete. That review should include reading the diff, to confirm it contains only intended changes, with no debugging leftovers, no commented-out code, no unrelated changes, and no accidental modifications to files you did not intend to touch, such as configuration files changed by a tool. It should also include checking that tests pass for the changed code, or at least that the changed functionality works as intended through a quick manual check, if automated tests are not yet available for that area, because committing broken code, even locally, creates a broken point in history that may confuse future debugging, if you later check out that commit and find it does not work. That combined review, diff plus message, is the core of pre-commit quality control, and it should not be skipped, even for small commits, because small commits can also contain mistakes, and their small size makes review quick, not unnecessary.

Another aspect of pre-commit review is checking for sensitive data, large files, and ignored files that were accidentally added, by reviewing the staged file list in status, in addition to the diff. A staged file list that includes a secrets file, a large data file, or an environment folder file indicates a problem with ignore setup or with staging, which should be corrected before committing, by unstaging the file and, if necessary, updating the ignore file, rather than committing it and then trying to remove it from history later, which is disruptive, especially if shared. Pre-commit review, by catching such inclusions before they become history, protects both the project and its collaborators, and it is far easier than post-commit correction, which may require history rewriting and coordination with all collaborators who have already pulled the mistaken commit.

Finally, pre-commit review should include a brief reflection on whether the commit is the right size and focus, as discussed earlier: is it small, focused, and complete, or does it mix unrelated changes that should be split, or is it incomplete, missing a test or a documentation update that belongs with it? That reflection, done before committing, lets you split or complete the commit while the work is still fresh, rather than committing a mixed or incomplete commit and then needing a follow-up commit to fix it, which clutters history with corrections that could have been avoided by a moment's reflection. Reviewing before committing, therefore, is not a bureaucratic delay, but a moment of craftsmanship, where you ensure that the history you are about to create is worthy of being part of the project's permanent record, and where you catch mistakes while they are still easy to fix, in the working copy, rather than after they have become history, where fixing them is harder and, if shared, disruptive to others.

Check: What should a pre-commit review check in the diff, the message, and the staged file list?

Section 31: Keeping Secrets Out of History

Keeping secrets out of version control history is a critical security practice, because once a secret, such as a password, an API key, a token, or a private key, is committed, it becomes part of history, visible to anyone with access to the repository, and removing it later requires rewriting history, which is disruptive and may not fully remove it from all copies, such as clones made before the removal, or backups, or cached copies on hosting services. Prevention is therefore far better than cure: never commit secrets, by keeping them out of code and configuration files that are tracked, by using ignore patterns for local configuration files that contain secrets, and by using secure, approved methods for managing credentials in real applications, rather than hard-coding them, as emphasised throughout this book and in the user's preference for built-in tools over pasting keys. A secret committed by accident should be treated as compromised, meaning it should be revoked or changed, not just removed from history, because anyone who saw it before removal could have copied it, and history removal alone does not guarantee it is no longer known.

Preventing secret inclusion starts with project structure: separate configuration that is shared, such as public settings, from configuration that is local or secret, such as credentials, by using different files, with the secret file ignored and documented as needing to be created locally, with placeholder values or instructions for obtaining real values securely, rather than with real values included. Code should read secret values from secure sources at runtime, not from hard-coded strings, and examples in documentation should use placeholder values, such as your-key-here, not real keys, even in private repositories, because private repositories can become public, can be shared with new collaborators, or can be copied, and a secret in a private repository is still a secret at risk.

Pre-commit review, discussed earlier, is a second line of defence against secret inclusion, because reviewing the staged file list and diff before committing can catch a secret file or a hard-coded secret that slipped past ignore setup, such as a secret added to a tracked file during debugging and forgotten, or a new configuration file that was not yet ignored. Automated secret scanning tools, which check commits for known secret patterns, can help as a third line of defence, by flagging potential secrets before they are pushed, but they are not a substitute for careful review and for good project structure, because they may miss novel secret formats, and they cannot prevent a secret that is committed without scanning, if scanning is not configured or is bypassed.

If a secret is committed, respond promptly and thoroughly: revoke or change the secret immediately, so that the exposed value is no longer valid, even if it remains in history, notify relevant parties, such as maintainers or security contacts, following the project's incident procedures, and then address history, by removing the secret from history through appropriate rewriting, if the project decides that is warranted, with coordination among collaborators, because rewriting shared history affects everyone who has cloned it, and they will need to update their copies accordingly.A useful habit is to review your ignore file whenever you add a configuration, because a pattern that covered yesterday's secrets may not cover today's new file format.

Check: Why must a committed secret be revoked, not just removed from history?

Section 32: Branch Naming and Organisation

Branch naming and organisation help a team navigate many parallel lines of work, by making each branch's purpose clear from its name, and by grouping branches in a way that reflects the project's workflow. A good branch name is descriptive, concise, and consistent with team conventions, often including a prefix indicating its type, such as feature, fix, docs, or experiment, followed by a brief, hyphenated description of its purpose, such as feature/task-search or fix/empty-list-crash. That prefix-plus-description format lets a reader scanning a branch list immediately understand what kind of work each branch contains and what it aims to do, without needing to examine its commits, which is valuable when a project has dozens of branches, as large projects often do. Consistency in naming, agreed by the team and documented, is more important than any particular format, because a consistent list is scannable, while an inconsistent list, with some branches named descriptively and others named vaguely, requires extra effort to interpret, and can lead to duplicate branches for the same task, because a developer did not recognise that a branch for their task already exists under a different name.

Branch organisation also involves lifecycle management: creating branches for tasks, working on them, merging them when complete, and deleting them after merging, so that the branch list reflects active work, not completed history, which remains in the main branch's history after merging, and does not need a separate branch to preserve it. That cleanup should be done regularly, perhaps after each merge, as part of the merge workflow, rather than left until the list becomes unmanageable, when cleanup itself becomes a large task requiring careful checking of each branch's status.

For personal projects, branch organisation can be simpler, because you are the only user, and you can remember your branches' purposes without strict naming, but adopting team-style naming and cleanup even for personal projects is good practice, because it builds habits that transfer to team work, and because your future self, returning to a project after months, will benefit from clear branch names and a tidy list, just as a collaborator would. That future-self benefit is a strong reason to name branches well even when working alone, because you are, in effect, collaborating with your future self, who does not share your current context and who needs clear names to understand past work.

Finally, branch organisation should align with the project's workflow, such as whether it uses a main branch plus feature branches, with releases tagged from main, or a more complex workflow with separate branches for development, staging, and production, each with its own purpose and merge rules. Those workflows, while beyond the scope of this introductory chapter, all depend on clear branch naming and lifecycle management, because their complexity makes organisation even more important: a developer must know which branch to work on, which branch to merge into, and which branches are stable versus experimental, and clear names and documentation are how that knowledge is communicated. Learning branch naming and organisation on simple projects, with a main branch and feature branches, prepares you for more complex workflows later, where the same principles, descriptive names, consistent conventions, lifecycle cleanup, and workflow documentation, scale to larger teams and more branches, keeping parallel development manageable rather than chaotic.

Check: Why is a consistent branch naming convention more useful than individually creative names?

Section 33: Team Workflow with Git

A team workflow with Git defines how team members create branches, share work, review it, and integrate it, so that collaboration is orderly rather than chaotic, and it typically follows a pattern: clone the project, create a branch for a task, work and commit on that branch, push the branch, open a pull request, receive review, address feedback with additional commits, and merge after approval, then pull the updated main branch and delete the feature branch, repeating that cycle for each task. That pattern, often called a feature-branch workflow, is widely used because it isolates each task's work, makes review easy, and keeps the main branch stable, as only reviewed, tested work is merged into it. Following that pattern consistently, rather than pushing directly to main, or working on main for all tasks, is a team discipline that protects shared code, because it ensures every change is reviewed before integration, and it makes history readable, because each merged branch corresponds to a task, with its commits and its pull request discussion preserved as a record of that task's development.

Team workflow also includes synchronisation habits: pulling frequently, as discussed earlier, to stay up to date with others' merged work, and pushing branches regularly, even before they are ready for review, as a backup and as a way to share work in progress with collaborators who may need to see it, though pushing work in progress should be clearly marked as such, through a draft pull request or a branch name indicating it is not yet ready, to avoid it being reviewed or merged prematurely. That regular synchronisation, pull often and push often, keeps local and remote histories close, minimises conflicts, and ensures work is not lost if a local computer fails, because pushed branches are backed up on the remote, even if they are not yet merged. A team member who works in isolation for weeks, without pulling or pushing, risks large conflicts and lost work, while one who synchronises daily, even for small progress, integrates smoothly and recovers easily from local problems, because their work is already shared, at least as a branch, if not yet as merged code.

Communication is an essential part of team workflow, beyond the technical commands, because Git alone does not tell a team who is working on what, which tasks are priorities, or how to resolve disagreements about design or implementation. Technical Git skills without communication skills produce a team that can merge code but cannot coordinate effectively, while the combination produces a team that can build a coherent project together.

Finally, team workflow should include agreements on handling conflicts, on reviewing, and on merging, such as who can approve a pull request, what tests must pass before merging, and how to handle a merge conflict that involves multiple authors' work, by communicating with the other author rather than resolving unilaterally in a way that might discard their intention. Learning team workflow on small, collaborative projects, or even by simulating it alone, by following the feature-branch pattern for your own tasks, prepares you for larger teams, where workflow discipline is essential for maintaining quality and cohesion as many people contribute to a shared codebase.

Check: What is the feature-branch workflow, and why does it keep the main branch stable?

Section 34: Git for the Capstone Project

The capstone project, PyDay, benefits from Git in the same ways as any Python project, but its standard-library-only, single-file design, as described in the outline, makes its Git story particularly simple and instructive for learners. PyDay's repository will contain a single main file, plus tests, a readme, and possibly a requirements note stating no external dependencies, all of which should be tracked, while its virtual environment, bytecode caches, and data file, which stores tasks and notes during use, should be ignored, because the data file is user data, not code, and it changes with every use, so tracking it would pollute history with user data changes that are not code changes. That ignore decision, data file ignored, code tracked, is a concrete example of the ignore principles from this chapter, applied to a real project, and it teaches an important distinction: version control tracks code and configuration, not user data, which belongs in a separate storage, such as a local file that is created and updated by the program, but not committed, or, for a real application, in a database or a user data folder outside the repository.

PyDay's development, across chapters 47 to 50, will involve multiple stages: planning, structure and storage, behaviour and commands, and finishing, each of which can correspond to a series of commits, or to a branch, depending on how you choose to organise it. A simple approach, suitable for a learner, is to work on the main branch, committing small, focused steps as you build each command, with clear messages, such as add add-task command, add list-tasks command, and so on, building a history that mirrors the capstone's construction, step by step, and that can be browsed later to see how the project grew. Either approach is valid, provided commits are small, focused, and messaged clearly, and provided the data file remains ignored, so that history stays clean and focused on code.

PyDay's Git history also serves as a portfolio artefact, because a future employer or collaborator who examines the repository can see not just the final code, but how it was built, through its commits, messages, and branches, which demonstrate your development process, your testing habits, if tests are committed alongside code, and your attention to clean history, which are qualities employers value beyond the code itself. Treat PyDay's Git history as part of the capstone's deliverable, not as an afterthought, by committing thoughtfully throughout its construction, rather than committing everything at the end, when the story of its construction is lost and cannot be reconstructed accurately from memory.

Finally, PyDay's simplicity makes it an ideal project for practising Git recovery skills, such as reverting a mistaken commit, resolving a conflict, if you simulate one by working on two branches that touch the same code, or recovering a deleted file from history, all in a low-stakes setting where mistakes do not affect collaborators or a production system. Use PyDay as a Git playground as well as a coding capstone, by trying branches, merges, stashes, and reverts on it, in a safe, personal repository, and you will finish the capstone not only with a working task manager, but with practical Git experience that transfers directly to professional work, which is a significant part of the capstone's value beyond its code alone.

Check: For PyDay, which files should be tracked in Git and which should be ignored, and why?

Section 35: Recovering Deleted or Lost Files

Recovering a deleted file from Git history is straightforward if the file was committed before deletion, because history preserves its contents at the last commit where it existed, and you can restore it by checking out that version, or by copying its contents from history into the working copy, without affecting other files or history. That recovery is a concrete benefit of committing frequently, because a file that was never committed cannot be recovered from history, as version control never recorded it, while a file committed even once can be recovered, even if it was later deleted and the deletion was itself committed, because history retains the earlier version. That process, though it involves a few steps, is far easier than recreating a file from memory, especially for a long or complex file, and it demonstrates why committing is a safety net, not just a history tool.

Recovering lost uncommitted changes is harder, and often impossible through version control alone, because uncommitted changes were never recorded, and version control has no copy of them to restore, unless they were stashed, in which case the stash preserves them, or unless an editor backup, a system backup, or a temporary file contains a copy. If uncommitted work is lost, check editor backups, temporary files, and system trash or recycle facilities, where a deleted file might still be recoverable, depending on how it was deleted, but do not rely on those as a primary safety net; rely on committing and stashing, which are deliberate, reliable preservation methods, unlike incidental backups that may or may not exist.

Recovering from a mistaken commit, such as committing a file you did not intend to include, or committing with a wrong message, depends on whether the commit has been shared, as discussed for amending and reverting. That distinction, local correction versus shared correction, applies to all recovery scenarios, and it is a key judgement in version control: ask, has this been shared, and if so, correct it by adding history, not by rewriting it, even if adding history feels less clean, because shared history's stability matters more than local cleanliness, and collaborators depend on it remaining stable.

Finally, recovery skills should be practised on a test repository, not learned for the first time during a real loss, because recovery under stress, when work is already lost, is harder than recovery practised calmly, when you understand the steps and their consequences. Create a small test repository, commit files, delete one, restore it, commit a mistake, amend it locally, share it with a second test clone, revert it there, and observe how each operation affects history, in both clones, to build an intuitive understanding of recovery as a set of operations with different scopes and safety levels. That practice, done once, will make you confident when a real loss occurs, because you will know which recovery method applies to your situation, whether it is restoring a deleted tracked file, recovering a stash, amending a local commit, or reverting a shared one, and you will be able to act quickly and correctly, rather than panicking or guessing, which are the responses that most often turn a recoverable loss into a permanent one.

Check: Why can a committed file be recovered after deletion, while an uncommitted file usually cannot?

Section 36: Git History as Documentation

Git history serves as documentation, in a way that complements readme files, comments, and other prose documentation, because it records not just the current state of a project, but how it arrived there, with messages explaining each change's rationale, and with diffs showing exactly what changed. A reader who wants to understand why a particular piece of code exists can search history for commits that touched it, read their messages, and see the context in which it was added, including whether it was added as a feature, as a bug fix, or as a workaround for a specific problem, information that code alone, which shows only the current implementation, cannot provide. That historical context is invaluable for maintenance, because it prevents a maintainer from removing code that looks unnecessary but actually fixes a subtle bug, or from changing code in a way that reintroduces a problem that was previously fixed, because history reveals the problem's existence and the fix's rationale, even if the current code does not make it obvious.

For history to serve as documentation effectively, commit messages must be clear and informative, as discussed earlier, because a history with vague messages, such as fixed bug, is not useful documentation, even if its diffs are precise, because the reader cannot tell from the message which bug was fixed or why the fix was needed, and must examine each diff in detail to guess, which is inefficient and error-prone. Conversely, a history with clear messages, such as fix empty list crash in overview by returning zero counts, is immediately useful, because it tells the reader what was fixed and why, even before they look at code, and it allows searching for relevant history by keywords, such as empty list or overview, to find all commits related to a particular area or problem. Writing messages for future readers, as if they were documentation, is therefore not just a style preference, but a practice that determines whether history can fulfil its documentation role, and it should be taken seriously for every commit, not just for major ones.

History as documentation also includes branch and pull request information, where available, because a branch's name, its pull request description, and its review discussion provide context that individual commit messages may not, such as the overall purpose of a feature, the alternatives considered, and the review feedback that shaped its final form. When reviewing history, therefore, consult pull request records where they exist, in addition to commit messages, to understand a change fully, and when creating pull requests, write descriptions that will serve as documentation for future readers, explaining the change's purpose and context, not just its code, because that description will be part of the project's permanent record, alongside its commits.

Finally, history documentation has limits: it records what changed and, if messages are good, why, but it does not record what was considered and rejected, unless that discussion is preserved in issues or pull requests, nor does it explain the project's overall architecture or vision, which belong in readme and design documents. Treat history as a living document, maintained through clear messages and thoughtful commits, and it will repay that maintenance many times over, in faster onboarding, safer maintenance, and better decisions.

Check: How can commit history prevent a maintainer from reintroducing a bug that was previously fixed?

Section 37: Common Git Mistakes

Common Git mistakes, like common testing or environment mistakes, are worth listing explicitly, because recognising them helps you avoid them, and recognising them during troubleshooting helps you fix them quickly. The first mistake is committing to the wrong branch, such as committing feature work to the main branch because you forgot to create or switch to a feature branch, which mixes feature work into stable code and makes it hard to review or revert separately. The fix, if the commit is local and unshared, is to move it to the correct branch, by creating a branch at that commit and resetting main back, with appropriate caution, or, if it is already shared, to revert it from main and reapply it on a feature branch, which is more involved but preserves shared history. Prevention is to check your current branch before committing, as a habit, especially after switching branches or starting new work, so that you commit to the intended branch, not to whichever branch you happened to be on.

The second mistake is committing unintended files, such as environment folders, bytecode files, secrets, or personal notes, because they were not ignored, or because a bulk add command added all untracked files without review. The fix depends on whether the commit is shared, as discussed for secrets and for amending, but prevention is to set up ignores early, to review status and diff before committing, and to add files selectively, rather than adding everything at once, especially in a project where new files appear frequently. The third mistake is writing vague commit messages, which, as discussed, makes history hard to use as documentation, and which is prevented by writing specific, imperative messages, and by reviewing the message against the diff before committing, to ensure it accurately describes the changes. Those three mistakes, wrong branch, unintended files, and vague messages, account for a large proportion of Git problems for beginners, and avoiding them, through branch checks, ignore setup, selective staging, diff review, and clear messages, will make your Git use significantly more reliable.

The fourth mistake is pulling or merging with uncommitted changes, leading to conflicts or to a messy working copy where merged changes and uncommitted changes are mixed, making it hard to tell which is which. The sixth mistake is forgetting to pull before pushing, leading to a push rejection because the remote has commits you do not have locally, which is actually a protective behaviour, not a bug, because it prevents you from overwriting others' work, and its fix is to pull, integrate, test, and then push, as part of the normal synchronisation loop, rather than forcing the push, which would discard others' work and is almost never correct.

Recognising these mistakes as patterns, rather than as isolated incidents, helps you develop a Git checklist, similar to environment checklists: check branch, check status, review diff, stage selectively, write a clear message, run tests, commit, verify, pull frequently, push after integrating, and never rewrite shared history. Checklists, like habits, turn Git from a set of commands to memorise into a workflow to follow, and that workflow, practised until automatic, makes version control a reliable, low-stress part of development, rather than a source of anxiety about losing work or breaking shared history.

Check: What are six common Git mistakes, and what habit prevents each?

Section 38: Git Workflow Checklist

A Git workflow checklist summarises the habits from this chapter into a sequence you can follow for each task, ensuring consistency and preventing missed steps, especially when you are busy or working on an unfamiliar project. A task-start checklist might include: pull the main branch to ensure you start from the latest stable code, create a branch for the task with a descriptive name, verify you are on that branch, and set up your environment for the project, if needed, before writing code. Starting correctly is half of working correctly, because many Git problems arise from starting on the wrong branch or from outdated code, which then require corrective work that could have been avoided by a careful start.

A work-in-progress checklist, followed during development, might include: commit small, focused steps frequently, with clear messages, run tests after each significant change, check status and diff before each commit, stage selectively, and push the branch regularly as a backup, even before it is ready for review, marked as work in progress if your team's conventions require it. That in-progress sequence keeps your work safe, through frequent commits and pushes, keeps history clean, through focused commits and diff review, and keeps you aware of your progress, through regular testing, which catches problems early, when they are easier to fix, rather than at the end of a task, when many changes have accumulated and a failure could have many causes. That rhythm, commit, test, push, repeat, in small cycles, is the core of effective Git use during development, and it should become automatic, like saving a file, rather than a chore to be done only at the end of a task.

A task-finish checklist, followed when a task is complete, might include: run the full test suite, review the branch's diff against main, to ensure it contains only intended changes, update documentation if needed, push the branch, open a pull request with a clear description, address review feedback with additional commits, and, after merging, pull the updated main branch locally and delete the feature branch, to keep your local branches tidy and up to date. Finishing cleanly, like starting cleanly, is a discipline that keeps a project tidy and a team coordinated, because every task ends in the same, predictable state: merged, cleaned up, and up to date.

Finally, adapt checklists to your workflow and to your team's conventions, rather than following a generic checklist rigidly, because different projects and teams have different requirements, such as additional checks before merging, specific branch naming, or specific review processes, which should be reflected in the checklist. Review and update checklists when you learn a new lesson, such as adding a secrets check after a secret was accidentally committed, or adding a pull-before-push step after a push rejection, so that checklists evolve with experience and prevent recurrence of past mistakes. Start with the checklists in this section, adapt them to your projects, and let them guide your Git workflow until the steps become habits, at which point the checklist serves as a backup for moments when habits fail, such as when you are tired or working under pressure, which are precisely the moments when mistakes are most likely and a checklist is most valuable.

Check: What should task-start, in-progress, and task-finish Git checklists each include?

Section 39: Git and Python Project Structure

Git and Python project structure interact, because the way you organise Python code, into modules, packages, tests, and configuration, affects what should be tracked, how branches should be organised, and how history should be read. A well-structured Python project, with code in a clear folder or file, tests in a separate folder, documentation in a readme, and configuration in tracked files, such as requirements and ignore files, makes Git history readable, because each commit's diff will show changes in a logical place, such as a new module in the code folder, a new test in the tests folder, and an updated requirement in the requirements file, rather than a jumble of files in a single folder where code, tests, data, and generated files are mixed. That structure, established at project creation, as discussed for templates, pays off in Git, because it makes status, diff, and log outputs clear and navigable, which in turn makes review, debugging, and onboarding easier, because a reader can find relevant changes quickly, by folder, rather than searching through a flat list of files.

Python project structure also affects ignore patterns, because different structures generate different files in different places, such as bytecode caches alongside source files, test outputs in a specific folder, or build outputs in a distribution folder, all of which should be ignored, as discussed earlier, but whose patterns depend on the project's layout. A project with code in a package folder will generate bytecode caches inside that folder, while a project with a single file will generate them alongside it, and the ignore patterns must cover both cases, typically by ignoring cache folder names wherever they appear, rather than by listing specific paths, which would be brittle if the structure changes. That generality is a good principle for ignore files, as it is for other configuration: prefer patterns that apply broadly, unless a specific exception is needed.

Branch organisation for Python projects often mirrors feature organisation, where a branch corresponds to a feature, a module, or a fix, rather than to a file, because features typically touch multiple files, such as code, tests, and documentation, which should be committed together on one branch, so that the branch's history tells the feature's story coherently, rather than splitting it across branches by file, which would fragment the story and make review harder. That feature-based branching, aligned with Python project structure, where features span code and tests, is a natural way to organise Git work, and it reinforces the principle of complete commits, where each commit, and each branch, leaves the project in a working state, with code and tests together.

Finally, Python project structure affects how Git history is used for releases and deployment, because a release, tagged from a specific commit, should correspond to a project state where code, tests, requirements, and documentation are consistent, so that checking out that tag gives a working project, not a project missing a requirement or a test file that was forgotten in a commit. Git and project structure, therefore, are not separate concerns, but intertwined: good structure makes Git history clear, and good Git habits make project structure's evolution visible and reliable, together supporting a Python project that is easy to develop, review, share, and release.

Check: How does a clear Python project structure make Git history easier to read and review?

Section 40: Learning Git by Practising

Learning Git by practising, on small, personal projects, is far more effective than learning it only by reading, because Git's concepts, such as staging, branching, merging, and history, become intuitive only through hands-on experience, where you see their effects on your own files and history, and where mistakes are low-stakes and easily corrected. A good practice routine starts with a test repository, created for learning alone, where you can experiment freely, without fear of damaging a real project, by initialising, adding files, committing, branching, merging, resolving a simulated conflict, stashing, reverting, and resetting, observing each operation's effect on status, log, and working copy, until the operations feel familiar. That experimental repository, which you can delete and recreate at will, is a safe laboratory for Git, similar to a test environment for code, where you can try operations, make mistakes, and learn from them, without consequences for real work, and it should be your first step before using Git on a project you care about.

Practising should progress from basic to advanced operations, in the order presented in this chapter, rather than jumping to advanced operations, such as rebasing or history rewriting, before mastering basics, such as staging, committing, branching, and merging, because advanced operations build on basic concepts, and misunderstanding basics will make advanced operations confusing and risky. Start with status, add, commit, log, and diff, until those feel natural, then add branching, switching, merging, and conflict resolution, until those feel manageable, then add remotes, pushing, pulling, and pull requests, by collaborating with yourself through two clones, or with a friend on a small project, to experience sharing and review in a real, though small, team setting. That progression, basics, then branching, then sharing, mirrors how Git is used in real projects, where you first work alone, then work in parallel, then share, and it ensures each layer is solid before the next is added, preventing the confusion that arises from trying to learn everything at once.

Practising should also include making and recovering from mistakes, deliberately, in a test repository, such as committing to the wrong branch, committing an unintended file, writing a vague message, or creating a merge conflict, and then fixing each mistake using the appropriate method, whether amending, unstaging, reverting, or resolving, as discussed earlier. That practice, like fire drills, prepares you for real incidents, by making recovery procedures familiar, so that when a real mistake occurs, you respond with a known procedure, rather than with panic or random commands, which are the responses most likely to turn a small mistake into a large loss.

Finally, practising Git should be ongoing, not a one-time exercise, because Git skills, like other skills, fade without use, and because new situations, such as a complex merge, a shared history problem, or a new team workflow, will arise as your projects grow, requiring you to learn new operations or to refine existing ones. Learning Git by practising, progressively, with deliberate mistakes and ongoing refinement, will make version control a confident, routine part of your development, rather than a mysterious tool you use hesitantly, and that confidence will serve you in every Python project you work on, alone or in a team, for the rest of your career.

Check: Why is practising Git in a test repository, including deliberate mistakes, better than learning only by reading?

Section 41: Git in a Solo Workflow

A solo workflow with Git, where you are the only developer, still benefits greatly from version control, even though collaboration features, such as pull requests and remote sharing, may seem unnecessary when working alone. Solo Git provides history, safety, and documentation, as discussed earlier, which are valuable even without collaborators: history lets you recall earlier versions and understand your own past decisions, safety lets you experiment and revert without fear, and documentation, through messages, helps your future self understand why you made changes, even after months away from a project. A solo developer who uses Git consistently, with small, focused commits and clear messages, will find their projects easier to maintain, easier to resume after breaks, and easier to share later, if the project becomes collaborative, because its history is already clean and its setup is already documented, rather than needing to be retrofitted for sharing at the last minute. Solo Git is therefore not overkill; it is preparation for future collaboration and for your own future needs.

A solo workflow can be simpler than a team workflow, because you do not need pull requests, reviews, or complex branch strategies, and you can work directly on the main branch for small projects, committing frequently, with the understanding that main is your working branch, not a shared stable branch that others depend on. A solo workflow that uses main for routine work and branches for larger or riskier work strikes a balance between simplicity and safety, adapting branch use to task size, rather than applying a rigid rule to all tasks.

Solo Git also involves backup, through remotes, even if you do not collaborate, because pushing your local repository to a remote hosting service, even a private one, provides an off-site backup of your history, protecting against local computer failure, theft, or accidental deletion, which could otherwise lose both your working copy and your local history. Pushing regularly, as part of your solo workflow, ensures the backup is current, not outdated, and it also prepares the project for sharing, if you later decide to collaborate, because the remote already exists and is up to date, requiring only that you grant access to collaborators, rather than setting up sharing from scratch under time pressure.

Finally, a solo workflow should include periodic review of your own history, by reading logs, checking for vague messages, cleaning up branches, and verifying that ignored files remain ignored, similar to team maintenance but done alone, as a form of self-review that keeps your projects tidy and your habits sharp. That review, done occasionally, perhaps when finishing a project or starting a new one, catches drift, such as a requirements file that is outdated, or a branch that was never merged or deleted, before it becomes a problem, and it reinforces good habits by making you conscious of them, rather than letting them become automatic but sloppy. Solo Git, practised thoughtfully, with frequent commits, clear messages, occasional branches, regular pushes for backup, and periodic review, gives a solo developer most of the benefits of version control, without the overhead of team processes, and it builds the habits that will make team Git feel natural when you join a team, because you will already understand history, branches, and sharing from personal experience.

Check: How does solo Git help you even when you have no collaborators?

Section 42: Git and Continuous Habits

Git habits become continuous when they are integrated into your daily development rhythm, rather than treated as separate tasks to be done occasionally, such as committing at the end of a day, or pushing only before a deadline, which leads to large, infrequent history updates that are hard to review and risky to lose. Continuous Git habits mean committing after each small, complete step, pulling at the start of each work session to stay current, pushing after completing a task or at the end of a session as a backup, and reviewing status and diff as part of each commit, all as automatic parts of development, like saving a file or running tests. That continuity keeps history granular, backups current, and integrations small, which, as discussed throughout this chapter, makes debugging, reviewing, and collaborating easier, because changes are recorded promptly, in small pieces, rather than accumulated into large, opaque batches that are difficult to understand or to integrate.

Continuous habits also involve integrating Git with other development habits, such as testing and environment management, so that they reinforce each other rather than competing for attention. Integrating Git with testing, environment, and documentation habits, by including all related changes in a single, complete commit, produces history that is coherent and usable, not just a record of code edits in isolation.

Continuous Git use also means staying current with remote changes, by pulling frequently, even when you are working alone on a branch, because remote changes, such as a teammate's merged work or a documentation update, may affect your work, and integrating them early, in small pieces, is easier than integrating them late, in a large batch, as discussed for team workflow. Even for a solo developer with a remote backup, pulling at the start of a session ensures your local copy includes any changes you made from another computer, if you work from multiple devices, which is a common solo scenario where continuous synchronisation prevents working from an outdated copy on one device while a newer copy exists on another.

Finally, continuous Git habits require a mindset that values history as a product, not just as a by-product, meaning you treat each commit as a contribution to a permanent record that others, and your future self, will read, rather than as a mere save point for your own convenience. That mindset motivates clear messages, focused commits, and clean branches, because you recognise that history's quality affects everyone who uses it, including yourself later, and that a little extra care at commit time, in reviewing diffs and writing messages, pays off many times over in faster onboarding, easier debugging, and smoother collaboration. Cultivating that mindset, by reviewing your own history periodically and asking whether it tells a clear story, and by learning from histories you admire, in open-source projects or in team projects, where history is exemplary, will improve your Git practice over time, turning it from a tool you use mechanically into a craft you practise thoughtfully, which is the difference between a developer who merely uses Git and one who uses it well.

Check: How do continuous Git habits, like committing after each small step, improve history quality?

Section 43: Handling Large Files

Large files pose a challenge for version control, because Git is designed for text files, such as code, where changes are small and diffs are meaningful, while large binary files, such as datasets, images, videos, or compiled outputs, change in ways that Git cannot efficiently diff, and storing many versions of them can bloat a repository, making cloning slow and history large, even if the large files themselves are not frequently changed. For Python projects, large files that commonly cause problems include datasets used for analysis, media files for a web project, and large data files generated during testing, all of which, if committed naively, can quickly make a repository unwieldy, especially if they are updated frequently, creating a new large version in history with each commit, even if only a small part of the file changed, because Git stores binary files as whole snapshots, not as line diffs, unlike text files where only changed lines are stored efficiently. Understanding that distinction, text versus binary storage, helps you decide which files belong in Git and which belong elsewhere.

For files that must be versioned but are large, specialised tools exist that store large files outside the main repository history, while keeping a small pointer file in Git that references the large file's version, allowing Git to track which version of a large file belongs to which commit, without storing the large file itself in history, and downloading it on demand when needed. Those tools, while beyond the scope of this introductory chapter, are the standard solution for projects that genuinely need large files in version control, such as machine learning projects with large datasets or models, and they should be considered when a project's large files cannot be avoided, rather than committing large files directly and bloating the repository. For most learning and small projects, however, the simpler solution is to avoid committing large files at all, by ignoring them, and by documenting how to obtain them separately, such as through a download link, a shared folder, or a generation script that recreates them from smaller source data, which keeps the repository small and fast while still making the project reproducible, provided the large files' source or location is documented.

Deciding whether a file is too large for Git depends on its size, its change frequency, and its importance to the project's code. That judgement, size versus necessity, applied file by file, keeps repositories lean without sacrificing reproducibility, by ensuring that only files that are truly needed as part of the code's history are committed, while large, regenerable, or separately obtainable files are excluded and documented.

Finally, if a large file was accidentally committed, removing it from future commits is not sufficient to shrink the repository, because history retains it in past commits, and cloning will still download it, unless history is rewritten to remove it, which, as discussed for secrets, is disruptive if shared, and should be done carefully with coordination, or, for a local, unshared repository, can be done more freely, though still with caution and backups.When in doubt, leave a large file out of the repository and write down where it lives, because a documented download is far easier to repair than a bloated history.

Check: Why do large binary files bloat a Git repository more than large text files?

Section 44: Git and Open Source Contribution

Contributing to open source through Git follows a workflow that extends team collaboration to a broader, often unfamiliar, community, where you do not have direct write access to a project's main repository, but instead fork it, meaning you create your own copy on a hosting service, clone your fork locally, create a branch for your contribution, work and commit on that branch, push it to your fork, and open a pull request from your fork's branch to the original project's main branch, requesting that your changes be integrated. That fork-and-pull-request workflow, which is standard for open source, allows anyone to propose changes to a project, without needing permission to push directly to its main repository, while still giving maintainers control over what is integrated, through review and approval, as discussed for pull requests generally. Understanding forking, as a personal copy for proposing changes, distinct from cloning, which is a local copy for working, helps you navigate open source contribution, where both steps are involved: fork on the hosting service, clone your fork locally, and then propose changes back to the original through a pull request.

Open source contribution also involves following a project's contributing guidelines, which typically specify how to set up the project, how to run tests, how to format code, how to write commit messages, and how to submit pull requests, among other expectations, and reading those guidelines before contributing saves time for both you and maintainers, because it prevents contributions that do not meet the project's standards and that require extensive revision or that are rejected for procedural reasons, rather than for code quality. Guidelines may also specify how to report issues, how to discuss proposals before coding, and how to handle licensing, such as agreeing that your contributions will be licensed under the project's licence, a topic covered in a later chapter on licences and open source. Respecting those guidelines, by reading them first, by following them carefully, and by asking questions politely when they are unclear, marks you as a thoughtful contributor, even if your first contribution is small, such as fixing a typo in documentation, which is a valuable and welcome way to start contributing, because it lets you learn the workflow with low stakes, while still helping the project.

Contributing to open source also involves communication etiquette, because you are interacting with maintainers and contributors who are often volunteers, with limited time, and who appreciate clear, concise, respectful communication, rather than demanding or vague requests. Open source is a community, and Git is its tool, but communication is its social fabric, and both matter for successful contribution.

Finally, open source contribution through Git is a valuable learning experience and a portfolio builder, because your public pull requests, commits, and issues demonstrate your skills, your ability to work with others' code, and your understanding of professional workflows, to future employers or collaborators who may review your public profile. Git skills, learned in this chapter, are the technical foundation for that cycle, and open source contribution is one of the most valuable ways to apply them, beyond personal and team projects, to a broader community of developers working together on shared software.

Check: What is the fork-and-pull-request workflow for contributing to a project you do not own?

Section 45: Git Troubleshooting Basics

Git troubleshooting basics involve diagnosing common problems by checking status, log, branch, and remote information, before attempting fixes, because those commands reveal the repository's actual state, which often differs from the assumed state that led to the problem. For example, a push rejection, where Git refuses to push because the remote has commits you do not have, is diagnosed by checking status and log, to see whether your local branch is behind the remote, and fixed by pulling, integrating, testing, and then pushing, as part of the normal synchronisation loop, rather than by forcing the push, which would discard others' work. A merge conflict, diagnosed by status showing conflicted files, is fixed by resolving, as discussed earlier, not by aborting blindly, unless you decide the merge itself was a mistake and you want to discard it entirely, in which case aborting, with a clean working copy backup, is appropriate. In each case, diagnosis through status, log, and branch, before fixing, prevents making the problem worse by acting on a wrong assumption about the repository's state.

Another troubleshooting principle is to avoid destructive commands when you are unsure, and to prefer safe, reversible operations, such as checking status, viewing diffs, creating a backup branch before a risky operation, and stashing before switching or pulling, over destructive operations, such as discarding changes, resetting, or force-pushing, which can lose work if used incorrectly. If a command's message is unclear, search for its exact wording, in documentation or in community resources, rather than guessing, because Git's commands have specific behaviours that are well-documented, and a precise search will usually reveal an explanation and a safe fix, while guessing may lead to a destructive operation that loses work.

Troubleshooting also involves knowing when to ask for help, and how to ask effectively, by providing the repository's state, through status and log outputs, the command you ran, its exact error message, and what you were trying to achieve, similar to asking for debugging help effectively, as discussed earlier. When in doubt, back up, ask, and then act, rather than acting first and asking later, after work is lost.

Finally, keep a personal note of Git problems you encountered and their solutions, similar to a debugging notebook, because Git problems tend to recur, especially the same few patterns, such as push rejections, merge conflicts, wrong-branch commits, and unintended files, and a note that records the symptom, the diagnosis, and the fix for each, will let you solve recurrences quickly, without relearning the solution each time. That note, reviewed occasionally, also helps you recognise patterns in your own Git use, such as frequently forgetting to pull before pushing, or frequently committing to the wrong branch, which indicate habits to improve, such as adding a pull step to your task-start checklist, or a branch check to your pre-commit checklist, turning troubleshooting into prevention. Git troubleshooting, like debugging, is a skill that improves with practice, with careful diagnosis, safe operations, effective help-seeking, and learning from past problems, and it will make you a more confident and reliable user of version control, capable of handling the inevitable problems that arise in real projects, without panic or loss.

Check: What diagnostic commands should you run before attempting to fix a Git problem?

Section 46: Git History and Privacy

Git history and privacy intersect, because history preserves information, including author names, email addresses, commit messages, and file contents, which may include personal or sensitive information that should not be shared widely, even if it was committed to a private repository initially, because private repositories can become public, can be shared with new collaborators, or can be cloned and copied beyond the original author's control, making history's contents effectively permanent and potentially public, even if the author later regrets including them. Author information, in particular, is recorded in every commit, and it typically includes a name and an email address, which, if set to a personal email, will be visible in history to anyone with access to the repository, and may be harvested for spam or unwanted contact, if the repository becomes public. Using a dedicated email address for version control, or a privacy-protected address provided by a hosting service, where available, can mitigate that risk, by separating your public commit identity from your personal email, while still identifying you as the author, which is important for attribution and for collaboration, where knowing who made a change helps with questions and review.

File contents in history also raise privacy concerns, beyond secrets, discussed earlier, because history may contain personal data, such as names, addresses, or other information about individuals, that was included in test data, in examples, or in documentation, without considering privacy implications, and that data, once committed, persists in history even if later removed from the working copy, unless history is rewritten, which is disruptive if shared. That anonymisation habit, applied to test data, examples, and documentation, protects privacy while preserving testing value, and it is a professional responsibility, especially for projects that handle user data or that will be shared publicly, where real personal data in history could cause harm or violate regulations.

Commit messages can also contain private information, if they reference internal issues, personal names, or sensitive decisions that were not meant to be public, and those messages, once committed and pushed, become part of shared history that is difficult to change without rewriting, as discussed for secrets. That restraint, messages about code, not about people or internal politics, is good practice for any shared history, because it keeps history professional and focused, and it avoids embarrassing or harmful disclosures that could arise if a private repository later becomes public or is shared more widely than originally intended.

Finally, when sharing a repository publicly, or when contributing to open source, review its history for privacy issues before publishing, by checking author information, file contents, and messages for personal data, secrets, or sensitive information, and address any issues found, through anonymisation, through history rewriting for a local, unshared repository, or through careful consideration and, if necessary, starting a fresh repository with a clean history for public sharing, while keeping the private history separate, if the private history cannot be cleaned safely. Treat history publication as a deliberate act, with a privacy check as part of it, similar to pre-commit review but at repository scale, and you will protect both yourself and others whose data might otherwise appear in your project's history.

Check: Why should you review a repository's history for personal data before making it public?

Section 47: Git for Documentation and Writing

Git is not only for code; it is also valuable for documentation, writing, and other text-based projects, such as this book, where chapters are written as text files, revised over time, and collaborated on by multiple authors, because Git's history, diff, branching, and merging capabilities apply equally well to prose as to code, with diffs showing line changes in text, branches allowing parallel drafting of different sections, and history providing a record of revisions, with messages explaining why a section was rewritten or reorganised. Using Git for writing projects teaches the same skills as using it for code, but in a context where changes are often larger and more subjective, requiring careful review and discussion, similar to code review, where reviewers comment on wording, structure, and clarity, rather than on logic and tests. That parallel, writing as a version-controlled project, helps learners see Git as a general tool for managing evolving text, not just as a code tool, which broadens its applicability to many kinds of work, from documentation to reports to creative writing, where version history and collaboration are equally valuable.

Writing with Git involves some adaptations from coding workflows, because prose files are often long, with lines that are paragraphs rather than code statements, making diffs harder to read if paragraphs are written as single long lines, where a small wording change appears as a whole paragraph changed, rather than as a few words changed. Adopting that style, or a similar line-break convention, for writing projects in Git is a practical tip that makes Git's diff tool, designed for line-based changes, work well for prose, where changes are naturally at sentence level.

Branching for writing projects also differs slightly from coding, because writing branches may correspond to chapters, sections, or revisions, rather than to features, and merging writing branches may involve resolving conflicts where two authors edited the same paragraph differently, which requires editorial judgement, not just technical reconciliation, to produce a coherent final text that incorporates both authors' intentions, or chooses one version over the other, with discussion, similar to resolving code conflicts but with a focus on readability and argument, rather than on logic and tests. Writing with Git therefore teaches collaboration skills that transfer to code, and vice versa, because both involve parallel work, review, and integration, differing only in the nature of the changes being integrated.

Finally, using Git for documentation in a Python project, such as its readme, its user guide, or its API documentation, ensures that documentation evolves alongside code, with commits that update both code and docs together, as discussed for complete commits, so that documentation never lags far behind code, because a code change without its documentation update is an incomplete commit, which should be completed before committing, or split into a code commit followed promptly by a docs commit, with a clear link between them. Git for writing, whether for a book, for documentation, or for reports, is therefore not a side application, but a central use case that reinforces Git's value as a general tool for managing evolving, collaborative text, of which code is just one important kind.

Check: How does writing prose one sentence per line improve Git diffs for writing projects?

Section 48: Git Safety and Backups

Git safety and backups involve protecting your work against loss, corruption, and mistaken operations, by using Git's own features, such as committing, pushing, branching, and stashing, as safety mechanisms, in addition to external backups, such as copying project folders or using system backup tools, which complement Git but do not replace it. Committing frequently, as discussed, is the first safety mechanism, because it records work in history, where it can be recovered, while uncommitted work exists only in the working copy, vulnerable to loss. Branching before risky operations, as discussed for resetting, is a third safety mechanism, because it preserves a reference to the current state, allowing recovery if the operation goes wrong, without needing to rely on more complex recovery tools.

External backups complement Git safety, by protecting against scenarios Git alone cannot, such as a remote repository being deleted or corrupted, or a local repository's history being rewritten incorrectly and then pushed, overwriting the remote's good history with a bad one, in which case an external backup, made before the rewrite, provides a copy that can be restored, while Git alone, after a forced push, may have lost the good history from both local and remote, if no other clone exists. For most learning projects, external backups are unnecessary, because the work is not critical and can be recreated, but for important personal or team projects, a simple external backup habit, such as copying the project folder to a separate drive periodically, or using a system backup tool that includes project folders, adds a layer of protection that is cheap and reassuring.

Git safety also involves protecting against mistaken operations, by using safe defaults and by confirming destructive operations, such as discarding changes, resetting, or force-pushing, before executing them, by checking status, diff, and branch, and by considering whether a backup is needed, as discussed throughout this chapter for each destructive operation. Cultivating that care, by checking before destroying, by backing up before rewriting, and by committing and pushing regularly, is the essence of Git safety, and it will protect your work throughout your career, regardless of which Git tools or hosting services you use.

Finally, safety involves knowing how to recover, not just how to prevent loss, because prevention, though important, cannot eliminate all mistakes, and recovery skills, such as restoring deleted tracked files, recovering stashes, and using history to find lost commits, as discussed earlier, are the safety net when prevention fails. Practising recovery, in a test repository, as discussed for learning Git, builds confidence that mistakes are often recoverable, which reduces panic when a real mistake occurs, and panic reduction itself improves recovery, because a calm developer checks status, thinks about the operation that caused the loss, and chooses a recovery method systematically, while a panicked developer may execute further destructive commands in an attempt to fix the problem quickly, making it worse. Git safety, therefore, is a combination of prevention, through frequent commits, regular pushes, backups, and careful checks, and recovery, through practised skills and calm, systematic troubleshooting, together ensuring that your work is protected and that mistakes, when they happen, are setbacks, not catastrophes, which is the goal of any professional workflow for managing valuable work.

Check: How do committing, pushing, and branching before risky operations each protect against loss?

Section 49: Git Habits for Long-Term Projects

Long-term projects, which evolve over months or years, with many contributors, many branches, and a large history, require Git habits that scale, beyond the basic habits for small projects, because history grows large, branches multiply, and the cost of mistakes increases with the project's importance and its number of users. That maintenance requires ongoing discipline, not just initial good intentions, with periodic reviews of history, branch cleanup, and message quality, similar to code reviews, where history quality is checked as part of pull request review, ensuring that merged history meets the project's standards, rather than allowing low-quality history to accumulate unchecked, which would degrade the project's record over time.

Another scaling habit is managing branches and releases systematically, with a clear workflow for main, feature, release, and hotfix branches, where each branch type has a defined purpose, a defined lifecycle, and defined merge rules, so that contributors know which branch to work on for each kind of task, and so that releases can be made reliably from stable branches, without including unfinished feature work that happens to be on main. Learning such workflows, by studying examples from open-source projects or by working on a team that uses them, builds on the basic branching skills from this chapter, extending them to larger, more structured collaboration, where branch organisation becomes a project management tool, not just a version control feature, and where clear documentation of the workflow is essential for new contributors to navigate it.

Long-term projects also require attention to repository size and performance, because history grows with every commit, and a repository with thousands of commits, many files, and possibly large files, can become slow to clone, to log, and to work with, if not managed, through practices such as ignoring large files, as discussed earlier, using shallow clones for contributors who do not need full history, and periodically reviewing history for bloat, such as accidentally committed large files that should be removed through history rewriting, with appropriate coordination, if the project is shared, or through other optimisation techniques that are beyond this introductory chapter but that build on its principles of clean history and thoughtful file management. Performance management for long-term repositories is an advanced topic, but its foundation is the same as for small repositories: keep history clean, keep ignored files ignored, keep large files out, and review periodically, so that growth does not degrade usability, and so that cloning a long-term project remains feasible for new contributors, even after years of development.

Finally, long-term projects require stewardship, where maintainers take responsibility for history quality, for workflow documentation, for onboarding new contributors, and for preserving the project's record, including its issues, pull requests, and releases, as a coherent archive that future maintainers can learn from, even after original contributors have left. Long-term Git habits, therefore, are not just about commands, but about stewardship, about caring for a project's history as a shared resource that outlives individual contributions, and that care, practised consistently, is what distinguishes a project that remains maintainable for years from one that becomes unmanageable as it grows, despite starting with good code, because its history, its workflow, and its community were not maintained with the same care as its code.

Check: Why do long-term projects need more systematic branch and history management than small projects?

Section 50: Git Habits to Keep

The Git habits from this chapter, taken together, form a reliable workflow for Python projects, whether solo or in a team, small or large, and keeping them consistently will serve you throughout your development career, regardless of which hosting services, tools, or workflows you encounter, because they are based on principles, not on specific commands, which may vary by tool but whose underlying concepts, recording, staging, branching, merging, sharing, and reviewing, remain constant. Those habits are: initialise repositories early, with ignore files for Python projects, check status frequently, stage selectively, review diffs before committing, write clear, specific messages, commit small, focused, complete steps often, run tests before committing, use branches for parallel work, merge carefully with testing and conflict resolution, push and pull regularly, use pull requests for review in teams, never rewrite shared history, keep secrets out of history, and periodically clean up branches, stashes, and old environments, to keep your workspace tidy. That list, though long, becomes automatic with practice, like a musician's scales, where each step is simple alone but powerful together, forming a workflow that protects work, shares it cleanly, and documents it clearly.

Keep those habits proportionate, as with environments, adapting them to project scale and to team conventions, rather than applying team processes rigidly to solo projects, or solo shortcuts to team projects, where they would disrupt collaboration. A solo project needs frequent commits, clear messages, and regular pushes for backup, but it does not need pull requests for every change, while a team project needs pull requests, reviews, and agreed branch workflows, in addition to solo habits, because its shared history affects others and must meet team standards. Adapting habits to context, while preserving their core principles, recording, reviewing, testing, and sharing carefully, is a sign of mature judgement, and it prevents both over-engineering, which wastes time on small projects, and under-engineering, which causes problems on large or shared projects, where the cost of a mistake is higher and the need for coordination is greater. Review your Git habits periodically, as with other habits, and adjust them as your projects and teams change, so that they remain appropriate and effective, rather than frozen in the form you first learned them.

Finally, remember that Git is a tool for people, not just for files, and its ultimate purpose is to help developers work together, safely and transparently, on shared software, by recording their work, by making it reviewable, and by preserving its history for future learners and maintainers, including your future self. Those human qualities, care, clarity, and respect for shared work, are what make Git effective in practice, beyond its technical features, and they are qualities that will serve you in every collaboration, whether through Git or through other tools, because they reflect a professional attitude towards shared work that is valued in any team, in any domain, and at any scale. Keep those habits, keep that attitude, and Git will be a trusted companion throughout your Python development journey, from your first personal project to your largest team contribution.

Check: What core Git habits should you keep for every Python project, solo or in a team?

Practical Task

Initialise a Git repository for a small Python project, create a .gitignore that excludes its virtual environment and bytecode caches, make at least five small focused commits with clear messages, create a feature branch, merge it, and practise recovering a deleted tracked file from history.

Chapter Questions

  1. Why is staging separate from committing, and how does it help you craft focused commits?
  2. Explain branches and merging, including what causes a merge conflict and how you resolve it.
  3. How do remotes, pushing, and pulling enable team collaboration, and what is a pull request for?
  4. Why must secrets never be committed, and what should you do if one is committed by accident?
  5. Why are small, frequent commits with clear messages better than large, infrequent commits?