v0.19 works and it survives errors, but to get there you touched storage.py, model.py, interface.py and __main__.py all at once. If tomorrow you discover that the days validation breaks something, how do you go back? And how do you know exactly what you changed on Tuesday? The answer everybody has been using for twenty years is Git: a system that keeps the project's full history, lets you return to any earlier point, lets you try out ideas without fear and makes it possible for several people to work on the same code without stepping on each other. This lesson teaches you Git from scratch: the mental model of the three areas, the day-to-day commands, how to write a good commit message, what is never versioned, how branches are opened and merged — and how a conflict is resolved without panic — how to undo what you did, and what GitHub and GitLab are. By the end, EasyTask will live in a repository with its own history.
Contents
- The problem:
final_project_v3_good_FINAL - Installing and configuring Git
- The mental model: three areas
- The day-to-day commands
- What a commit is and how to write its message
.gitignore: what is never versioned- Branches: working without fear
- Conflicts: what they are and how to resolve them
- Undoing:
restore,revertandreset - Remotes: GitHub, GitLab and pull requests
- Tags and versioning
- EasyTask v0.20: the project under Git
- Common mistakes and tips
- Exercises
- Conclusion
- The problem:
final_project_v3_good_FINAL
final_project_v3_good_FINALEveryone has lived through this scene: a folder holding report.docx, report_v2.docx, report_v2_revised.docx, report_final.docx and report_final_GOOD.docx. With code it is worse, because on top of that you need to know which line changed between two versions and why. Copying folders fails on every count: it takes up space, it does not say what changed, it does not explain the reason, it does not let you merge two people's work and there is no way to recover just one part.
A version control system (VCS) solves exactly that: it stores snapshots of the project with their date, their author and an explanation, and it lets you compare, go back and combine changes. There are two families. The centralised ones (Subversion, CVS) keep the history on a single server: without a connection you cannot work, and if the server goes down, that is that. The distributed ones (Git, Mercurial) give each person a complete copy of the history on their own computer: you work offline, every clone is a backup and you only sync with the server when it suits you. Git, created in 2005 for Linux development, is today the absolute standard.
- Installing and configuring Git
Git is downloaded from git-scm.com (on Linux it usually comes preinstalled or is installed with the package manager). To check it and configure it, three commands that are run once per computer only:
git --version # check that it is installed git config --global user.name "Marta Ruiz" # who signs the commits git config --global user.email "[email protected]" git config --global init.defaultBranch main # name of the initial branch git config --list # see the whole configuration
Your name and email are not a formality: they are recorded forever in every commit you make, and they are what shows up when someone asks who touched a line. The --global option applies them to all your projects; without it, only to the current repository, which is what you use when work and personal email must be kept apart.
- The mental model: three areas
This is the part that is hardest at first, and it all becomes clear once you understand that a change passes through three areas before it is stored in the history:
- Working directory: your files exactly as they are on disk right now.
- Staging area (or index): the list of changes you want to include in the next commit. It is Git's great insight: it lets you choose what goes in and what does not.
- Repository: the
.gitfolder, where all the committed snapshots live.
flowchart LR
A[Working directory] -->|git add| B[Staging area]
B -->|git commit| C[Local repository]
C -->|git push| D[Remote repository]
D -->|git pull| A
B -->|git restore --staged| A
| Area | What it contains | How you get in | How you get out |
|---|---|---|---|
| Working directory | The files on disk | By editing | git restore file |
| Staging area | What will go into the next commit | git add |
git restore --staged |
| Repository | The committed history | git commit |
git revert |
Thanks to the middle area you can have touched five files and commit only two, leaving the rest for another commit with its own explanation. That discipline — one commit, one idea — is what turns the history into something worth reading.
- The day-to-day commands
These ten commands cover 95 % of the work:
| Command | What it does |
|---|---|
git init |
Creates the repository in the current folder (.git appears) |
git status |
The most used one: what you have changed and which area it is in |
git add file |
Moves a change into the staging area (git add . for everything) |
git commit -m "message" |
Commits what is staged as a snapshot |
git log --oneline |
Compact history, one commit per line |
git diff |
What you have changed and have not staged yet |
git diff --staged |
What is staged for the next commit |
git show <hash> |
Every detail of one specific commit |
git restore file |
Discards that file's unsaved changes |
git rm file |
Deletes the file and records the deletion |
This is what a real session on EasyTask looks like:
$ git status
On branch main
Changes not staged for commit:
modified: easytask/storage.py
modified: README.md
$ git add easytask/storage.py # only this one: the README goes in another commit
$ git commit -m "Survive a corrupted or missing tasks.json"
[main 4f2a9c1] Survive a corrupted or missing tasks.json
1 file changed, 12 insertions(+), 3 deletions(-)
$ git log --oneline
4f2a9c1 Survive a corrupted or missing tasks.json
9b1e73d Document the package and add README
a07c5e2 Initial version of EasyTaskNotice the important detail: a single file was staged even though two were modified. The resulting commit tells a clean story, and README.md will wait for its own commit. git status is your compass: run it before and after every operation until the mental model comes naturally. And before committing, git diff shows you exactly what you are about to store:
$ git diff
diff --git a/easytask/storage.py b/easytask/storage.py
@@ -38,7 +38,10 @@ def load_tasks(path=JSON_PATH):
- with open(path, encoding="utf-8") as f:
- return Agenda.from_dict(json.load(f))
+ try:
+ with open(path, encoding="utf-8") as f:
+ return Agenda.from_dict(json.load(f))
+ except FileNotFoundError:
+ return Agenda()The lines with - are the ones that disappear and the ones with + are the ones that come in; the header @@ -38,7 +38,10 @@ tells you which part of the file you are looking at. Reading a diff is the fastest way to review your own work before committing it.
- What a commit is and how to write its message
A commit is four things at once: a snapshot of the whole project, a message that explains the why, an author with a date and a parent, the previous commit. That chain of parents is the history, and each commit is identified by a hash, a code like 4f2a9c1 used to refer to it in any command.
The message is the part most often neglected and the one that pays off most six months later. The rules accepted in every project are three: a short subject (under 50 characters), in the imperative and with no full stop — "Add", "Fix", "Remove", as if you were completing the sentence "this commit will..." — and, if needed, a blank line and a body that explains the why, never the what (the what is already in the diff).
| Bad message | Why | Good message |
|---|---|---|
changes |
Says nothing | Add priority validation to Task |
fixed |
What was broken? | Fix progress going over 100% |
asdf |
Pure noise | Extract ask_priority into interface.py |
Changed interface.py and storage.py and also... |
Too many things together | Two commits, one per idea |
git commit -m "Set the corrupted JSON aside instead of losing it" -m "A damaged file stopped startup. It is now renamed to .json.bak and we carry on with an empty agenda, so Marta can review it later."
The second -m creates the body. That text is exactly what you will be grateful for when, a year from now, you wonder why there is a .json.bak file in the folder.
.gitignore: what is never versioned
.gitignore: what is never versionedNot everything in the folder belongs in the repository. A file called .gitignore, in the project root, tells Git what to ignore:
# Virtual environment and Python cache .venv/ __pycache__/ *.pyc # Local data and logs: everyone has their own tasks.json tasks.json.bak tasks.csv easytask.log # Editor and system configuration .vscode/ .DS_Store # NEVER credentials .env *.key
The four categories are always the same: what can be regenerated (__pycache__, the .venv, which is rebuilt from requirements.txt), each user's data (Marta's tasks.json is not Luis's), the editor's personal configuration and, above all, secrets.
That last one deserves a serious warning: never push passwords, API keys or personal data to a repository. And it is not enough to delete them later in a follow-up commit, because they are still in the history and anyone can recover them; if the repository is public, there are bots that hunt down leaked keys within minutes. The rule is simple: credentials go in a .env file ignored from the very start, and if one escapes, it is revoked immediately. The same goes for real clients' personal data.
- Branches: working without fear
A branch is a parallel line of development. It lets you work on something — a new feature, an experiment, a fix — without touching the version that works, and it lets two people move forward at once without getting in each other's way. In Git they are so cheap that they are used constantly.
git branch # see the branches (the current one has a *) git switch -c export-pdf # create a branch and switch to it ... you work and make commits ... git switch main # go back to the main one git merge export-pdf # bring the branch's work in here git branch -d export-pdf # delete it once it is merged
git switch is the modern form; you will see a lot of git checkout in older documentation, which does the same thing (and more, which is what made it confusing).
gitGraph
commit id: "Initial version"
commit id: "Document package"
branch export-pdf
commit id: "Add PDF export"
commit id: "Adjust margins"
checkout main
commit id: "Fix progress"
merge export-pdf
commit id: "Version 0.20"
In the diagram, main kept moving forward while the export-pdf branch did its work, and the merge joined the two histories in a merge commit. While the branch existed, main was always in a working state: that is what "working without fear" means.
- Conflicts: what they are and how to resolve them
A conflict appears when two branches have changed the same lines of the same file and Git cannot decide which one wins. It is neither an error nor a catastrophe: it is Git asking you to decide. When merging you will see something like this:
Auto-merging easytask/interface.py CONFLICT (content): Merge conflict in easytask/interface.py Automatic merge failed; fix conflicts and then commit the result.
And inside the file, the disputed lines appear marked:
<<<<<<< HEAD
WIDTH = 52 # what is in the current branch (main)
=======
WIDTH = 60 # what the branch you are merging brings
>>>>>>> export-pdfResolving it takes three steps, and none of them is mysterious: open the file and leave the correct content, deleting the three marker lines (<<<<<<<, =======, >>>>>>>); stage the file with git add easytask/interface.py; and close the merge with git commit. If you have made a mess of it, git merge --abort leaves everything as it was before you tried. The practical advice for having few conflicts: short branches, small commits and merging often.
- Undoing:
restore, revert and reset
restore, revert and resetGit lets you undo almost anything, but not every way is equally safe:
| Command | What it does | Safe on shared work? |
|---|---|---|
git restore file.py |
Discards that file's uncommitted changes | Yes |
git restore --staged file.py |
Takes it out of the staging area | Yes |
git revert <hash> |
Creates a new commit that undoes an earlier one | Yes: the recommended one |
git reset --soft <hash> |
Moves the branch back, keeping the changes | Locally only |
git reset --hard <hash> |
Moves the branch back and deletes the changes | Dangerous |
$ git log --oneline e5f80b2 Show the task summary by assignee 4f2a9c1 Survive a corrupted or missing tasks.json $ git revert 4f2a9c1 # undoes that commit, without deleting it $ git log --oneline 8d10c4a Revert "Survive a corrupted or missing tasks.json" e5f80b2 Show the task summary by assignee 4f2a9c1 Survive a corrupted or missing tasks.json
Notice that the original commit is still there: revert does not delete it, it adds another one on top that applies exactly the opposite changes. That is the key difference: revert adds history, reset rewrites it. If the commit is already shared with other people, rewriting the history breaks their repository, so the right answer is almost always git revert. And git reset --hard deserves special respect: it deletes work without asking and there is no recycle bin. Use it only on your own computer, on commits nobody else has seen, and after checking with git status that you are not leaving anything behind.
- Remotes: GitHub, GitLab and pull requests
A remote is a copy of the repository hosted somewhere else, usually on a service such as GitHub or GitLab. It works as a backup, as the team's meeting point and as a shop window: today a public repository is part of any programmer's CV.
| Command | What it does |
|---|---|
git clone <url> |
Downloads a whole repository with all its history |
git remote add origin <url> |
Connects your local repository to a remote one (origin is the usual name) |
git push -u origin main |
Uploads your commits to the remote (the first time, with -u) |
git fetch |
Brings the remote's changes without merging them into your work |
git pull |
fetch + merge: brings them and integrates them into your branch |
And one more piece you will meet as soon as you work with others: a pull request (or merge request on GitLab) is a formal request to merge your branch into the main one. It opens a page where the team sees the changes line by line, comments and approves before integrating. It is the mechanism used to review code in practically every project, and it works exactly on top of the branches you already know how to create.
- Tags and versioning
A tag is a permanent name for one specific commit, and its natural use is to mark released versions — precisely the numbers you have been seeing since 08-01:
git tag -a v0.20 -m "Project under version control" git tag # list the tags git show v0.20 # see which commit it points to git push origin v0.20 # tags are pushed separately
Unlike a branch, a tag does not move: v0.20 will point at that commit forever, so a year from now you will be able to recover exactly the code that was delivered. This is where the semantic versioning of 08-01 becomes tangible: v0.19, v0.20, v1.0 stop being a mental convention and become concrete, recoverable points in the history.
- EasyTask v0.20: the project under Git
Let us put the package under version control from scratch, with three commits that tell a story and a branch for one improvement:
$ cd ~/projects/easytask $ git init Initialized empty Git repository in /home/marta/projects/easytask/.git/ $ printf '.venv/\n__pycache__/\n*.pyc\ntasks.json\neasytask.log\n' > .gitignore $ git add .gitignore $ git commit -m "Add .gitignore with environment, cache and local data" $ git add easytask/ README.md $ git status # check that tasks.json is NOT going in $ git commit -m "Add the documented easytask package (v0.18)" $ git add easytask/storage.py easytask/model.py easytask/interface.py $ git commit -m "Handle load and validation errors (v0.19)" $ git tag -a v0.19 -m "The program stops breaking"
The order is not accidental: the .gitignore goes first, before adding anything else, so that the tasks.json with the real data never enters the history. Now, the improvement on a branch:
$ git switch -c summary-by-assignee $ ... edit agenda.py and interface.py ... $ git add easytask/agenda.py easytask/interface.py $ git commit -m "Show the task summary by assignee in the menu" $ git switch main $ git merge summary-by-assignee Updating 7c3d1a9..e5f80b2 Fast-forward easytask/agenda.py | 14 ++++++++++++++ easytask/interface.py | 8 ++++++++ $ git branch -d summary-by-assignee $ git log --oneline --graph * e5f80b2 (HEAD -> main) Show the task summary by assignee * 7c3d1a9 (tag: v0.19) Handle load and validation errors (v0.19) * 9b1e73d Add the documented easytask package (v0.18) * a07c5e2 Add .gitignore with environment, cache and local data
Fast-forward means main had not moved forward while you were working, so Git simply advanced the pointer: not even a merge commit was needed. EasyTask v0.20 now has a history, authorship, messages that explain the why, a tag and the ability to return to any earlier point.
Common Mistakes and Tips
- Committing the virtual environment or the data. Create the
.gitignorebefore the firstgit add .; taking something out of the history afterwards is far more awkward. - Committing passwords or personal data. They stay in the history even if you delete them later. Use an ignored
.envand revoke any key that escapes. - Giant commits. "This week's changes" with 40 files is useless. One commit, one idea.
- Empty messages (
changes,fix,asdf). Write in the imperative what the commit does and, if it is not obvious, why. - Always working on
main. For anything that is not trivial, open a branch: if it goes wrong, you delete it and nothing happened. - Using
git reset --hardto "clean up". It deletes work without asking. On anything shared, usegit revert. - Tip:
git statusandgit diffbefore every commit. Looking at what you are about to commit avoids 90 % of the scares and of the files that sneak in by mistake.
Exercises
Exercise 1: Commit messages
Rewrite these four messages following the rules above, and say in which of them you would split the work into more than one commit:
various fixesI modified the interface.py file so that the menu now also shows the export option and while I was at it I fixed a bug in the progress calculationwipfunction added
Exercise 2: .gitignore for a new project
Marta is starting reports, a program that reads EasyTask's tasks.json, keeps templates in templates/, generates PDFs in output/, uses a .venv virtual environment and needs a mail API key. Write its .gitignore explaining every line, and say what should be versioned.
Exercise 3: A branch with a conflict
Describe, command by command, this whole situation: you create the short-menu branch, change WIDTH = 52 to WIDTH = 48 in interface.py and commit; meanwhile, on main, someone else changed that same line to WIDTH = 60 and committed. Merge, resolve the conflict leaving WIDTH = 48 and close the operation.
Solutions
Solution 1.
| Original | Rewritten |
|---|---|
various fixes |
Fix the progress rounding in Task.percentage |
| The long one in point 2 | Two commits: Add the export option to the menu and Fix the progress calculation |
wip |
Extract ask_priority into interface.py (and if it really is half-done, it is not committed to main: it stays on its branch) |
function added |
Add summary_by_assignee to Agenda |
The interesting case is the second: it mixes two independent ideas, and that has a very concrete practical consequence. If tomorrow the progress fix has to be undone, a single commit would take the menu option down with it. One commit per idea is what makes git revert a surgical operation rather than a demolition.
Solution 2.
# Virtual environment: rebuilt from requirements.txt .venv/ # Python cache: regenerates itself __pycache__/ *.pyc # Input data and results: every user has their own tasks.json output/ # Credentials: NEVER into the repository .env
What should be versioned is everything that is the project and not a product of it: the source code, the README.md, the requirements.txt, the templates/ folder — because the templates are part of the program, not a result — and a .env.example with empty keys, which documents which variables are needed without revealing any. The rule that sums it all up: version what you write; ignore what is generated, what is personal and what is secret.
Solution 3.
$ git switch -c short-menu $ ... change WIDTH to 48 in easytask/interface.py ... $ git add easytask/interface.py $ git commit -m "Reduce the menu width to 48 columns" $ git switch main # main already has the change to 60 $ git merge short-menu Auto-merging easytask/interface.py CONFLICT (content): Merge conflict in easytask/interface.py $ ... open the file, leave only 'WIDTH = 48' and delete <<<<<<<, ======= and >>>>>>> $ git add easytask/interface.py # 'add' is how you say "resolved" $ git commit -m "Merge short-menu: keep WIDTH at 48" $ git branch -d short-menu
Three details worth fixing in your mind. The conflict has broken nothing: until you commit, the repository is in an intermediate state you leave with git commit or flee from with git merge --abort. git add is the signal for "this is resolved", not a different operation. And you must review the whole file before committing, because it is easy to leave a forgotten >>>>>>> marker that would turn the module into code with a syntax error.
Conclusion
A version control system replaces the _v3_good_FINAL folders with a real history: snapshots with a date, an author and an explanation, with the ability to compare, go back and combine several people's work. Git is distributed, so every clone holds the complete history. It is configured once with git config --global user.name and user.email, which sign all your commits. Its mental model is three areas — working directory, staging area and repository — and a change's journey is always add → commit → (push). The day-to-day fits in ten commands: init, status, add, commit -m, log --oneline, diff, show, restore and rm, with git status as your permanent compass. A commit is a snapshot, a message, an author and a parent, and its message is written in the imperative, short and explaining the why, with one commit per idea. The .gitignore leaves out the virtual environment, __pycache__, local data, the editor's configuration and — this one without exceptions — credentials, which stay in the history even if you delete them later. Branches (branch, switch -c, merge, -d) let you work without touching the good version, and a conflict is not a catastrophe: you edit the file leaving the correct content, delete the <<<<<<<, ======= and >>>>>>> markers, run git add and commit. To undo, restore for what is uncommitted and revert for what is already committed — it adds history instead of rewriting it — leaving reset --hard for the strictly local. Remotes (clone, remote add, push, pull, fetch) take the project to GitHub or GitLab, where a pull request lets the code be reviewed before it is integrated. And tags (git tag -a v1.0) fix the semantic versioning numbers forever.
EasyTask is now v0.20: a repository with its .gitignore, three commits that explain what was done and why, a v0.19 tag and a branch merged and deleted. You can experiment without fear, because nothing is lost. And yet there is something Git cannot tell you: whether the code you have just committed works. Every time you touch Agenda.sorted_tasks() or Task's validation, you still start the menu and try options by hand, one by one, praying you have not broken anything that already worked. That is slow, it is boring and it gets done badly. In Automated testing you will build the safety net that was missing: a tests/ folder with tests that check on their own, in one second, that everything is still in place.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
