Professional development Core
Git and GitHub
Track every change with Git, work on branches and resolve a real merge conflict, keep node_modules and secrets out of the repository, and share work on GitHub.
BY THE END OF THIS LESSON YOU CAN
- Record changes as small commits with git add, git commit, git status and git log
- Keep node_modules and .env out of a repository with .gitignore, and check why a file is ignored
- Work on a branch, merge it back and resolve a merge conflict by hand
- Respond correctly to a committed secret, before and after it was pushed
- Connect a repository to GitHub, push and pull, and propose a change with a pull request
Why version control
You change a file, the server breaks, and you cannot remember what the working version looked like. Or two people edit the same project and overwrite each other's work. Version control solves both: it records every change, who made it and why, lets you go back to any earlier state, and merges the work of many people.
Git is the version control system almost everyone uses. It runs on your computer. GitHub is a website that stores Git projects online so a team can share them; GitLab and Bitbucket are similar. Git works without GitHub, so start with Git.
Install it from git-scm.com (Windows), with xcode-select --install or brew install git (macOS), or with sudo apt install git (Linux). Then tell Git who you are. Every change you record carries this name and e-mail:
git --version git version 2.53.0 git config --global user.name "Ada Lovelace" git config --global user.email "[email protected]" git config --global init.defaultBranch main
Use your own name and e-mail. --global saves the settings for every project on this computer. The last line names the first branch main, which GitHub also uses.
Repositories and commits
A repository (repo) is a project folder whose history Git tracks. git init turns a folder into one by creating a hidden .git folder, where the whole history lives. To keep the output short, this lesson starts a fresh folder with a tiny server.js and a README.md; afterwards, put your plain-api folder from Build a plain Node.js Task API under Git the same way:
mkdir task-api cd task-api git init Initialized empty Git repository in ~/task-api/.git/ git status On branch main No commits yet Untracked files: (use "git add <file>..." to include in what will be committed) README.md server.js nothing added to commit but untracked files present (use "git add" to track)
git status is the command you will type most. It says the two files are untracked: Git sees them but is not recording them yet.
Recording a change takes two steps. git add puts files on the staging area, the list of what goes into the next snapshot. git commit takes the snapshot, a commit, with a message saying what changed:
git add server.js README.md git status --short A README.md A server.js git commit -m "Add the first server" [main (root-commit) f2a3cfe] Add the first server 2 files changed, 11 insertions(+) create mode 100644 README.md create mode 100644 server.js git log --oneline f2a3cfe Add the first server
A means "added to the staging area". f2a3cfe is the start of the commit's hash, a unique id Git computes from the content; yours will differ. Write messages that say what the commit does, in a few words: "Add the first server", not "stuff" or "fix".
TIP
Commit small and often: one commit per finished step. A history of small commits is easy to read, easy to undo one piece at a time, and easy to search for the commit that introduced a bug, which Debugging practice does withgit bisect..gitignore: node_modules and .env
Two things must never go into a repository:
node_modules/: thousands of files thatnpm installrecreates frompackage.jsonandpackage-lock.jsonat any time..env: the file where you keep secrets for your computer, such as the Task API's key. Anyone who can read the repository could read them.
Turn the folder into an npm project, install a dependency (ms, from npm and packages), and create a .env with a random API key, the way Build a plain Node.js Task API did:
npm init -y Wrote to ~/task-api/package.json: … npm pkg set type=module npm install ms added 1 package, and audited 2 packages in 7s found 0 vulnerabilities node -e "console.log('TASKS_API_KEY=' + crypto.randomBytes(32).toString('hex'))" > .env git status On branch main Untracked files: (use "git add <file>..." to include in what will be committed) .env node_modules/ package-lock.json package.json nothing added to commit but untracked files present (use "git add" to track)
Git is offering to track .env and node_modules/. One careless git add . would record both. Create a file named .gitignore in the project folder, with one pattern per line, before that happens:
cat .gitignore node_modules/ .env git status On branch main Untracked files: (use "git add <file>..." to include in what will be committed) .gitignore package-lock.json package.json nothing added to commit but untracked files present (use "git add" to track) git check-ignore -v .env node_modules .gitignore:2:.env .env .gitignore:1:node_modules/ node_modules git add . git commit -m "Add package.json and .gitignore" [main 38d1d9b] Add package.json and .gitignore 3 files changed, 41 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json
Both are gone from the list. git check-ignore -v explains which line of .gitignore hides a file, which helps when you wonder why a file is not showing up. .gitignore itself is committed, so everyone on the team gets the same rules.
Your code reads the secret from the environment, never from a string in a file you commit. A good habit is to fail at startup, with a clear message, when a required variable is missing:
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing environment variable ${name}. Add it to .env or your shell.`);
}
return value;
}
try {
const apiKey = required("TASKS_API_KEY");
console.log(`API key loaded (${apiKey.length} characters)`);
} catch (error) {
console.log(error.message);
}
node config.jsMissing environment variable TASKS_API_KEY. Add it to .env or your shell.
Here the variable is not set, so the program says exactly what is missing instead of failing later in a confusing way. When it is set, the program logs only the key's length, never the key. Node.js loads the .env file for you with node --env-file=.env server.js, as in What Node.js is, and the project commits a .env.example with the variable names and no real values, so a new developer knows what to fill in.
Branches and merging
A branch is a separate line of work. You make a branch for a feature, commit on it without disturbing main, and merge it back when it is done. Make a branch that reads the port from the PORT environment variable, change the last line of server.js in your editor, and commit:
git switch -c port-from-env Switched to a new branch 'port-from-env' git diff diff --git a/server.js b/server.js index 4bf9816..4685a08 100644 --- a/server.js +++ b/server.js @@ -5,4 +5,4 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ status: "ok" })); }); -server.listen(3000); +server.listen(Number(process.env.PORT ?? 3000)); git commit -am "Read the port from PORT" [port-from-env 1ad8c1b] Read the port from PORT 1 file changed, 1 insertion(+), 1 deletion(-)
git switch -c creates a branch and moves to it. git diff shows what changed and is not staged yet: - lines were removed, + lines added. commit -a stages every tracked file that changed, so you can skip git add for files Git already knows.
Meanwhile, someone changes the same line on main, to log a message when the server starts:
git switch main Switched to branch 'main' git commit -am "Log when the server starts" [main 5718881] Log when the server starts 1 file changed, 1 insertion(+), 1 deletion(-) git log --oneline --graph --all * 5718881 Log when the server starts | * 1ad8c1b Read the port from PORT |/ * 38d1d9b Add package.json and .gitignore * f2a3cfe Add the first server
The graph shows the two lines of work splitting after 38d1d9b. Now merge the branch into main:
git merge port-from-env Auto-merging server.js CONFLICT (content): Merge conflict in server.js Automatic merge failed; fix conflicts and then commit the result. git status On branch main You have unmerged paths. (fix conflicts and run "git commit") (use "git merge --abort" to abort the merge) Unmerged paths: (use "git add <file>..." to mark resolution) both modified: server.js no changes added to commit (use "git add" and/or "git commit -a") cat server.js import http from "node:http"; const server = http.createServer((req, res) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); }); <<<<<<< HEAD server.listen(3000, () => console.log("Listening on port 3000")); ======= server.listen(Number(process.env.PORT ?? 3000)); >>>>>>> port-from-env
Resolving the conflict
When both sides changed different lines, Git merges by itself. Here both changed the same line, so Git cannot know which one you want. That is a merge conflict, and it is normal, not an error. Git wrote both versions into the file between markers:
- From
<<<<<<< HEADto=======: the version on your current branch,main. - From
=======to>>>>>>> port-from-env: the version on the branch you are merging.
Here you want both ideas: the port from the environment and the log message. Edit the file so it holds the final code, delete all three marker lines, then git add the file to mark it as resolved and commit:
tail -n 2 server.js const port = Number(process.env.PORT ?? 3000); server.listen(port, () => console.log(`Listening on port ${port}`)); git add server.js git commit --no-edit [main a429d21] Merge branch 'port-from-env' git log --oneline --graph * a429d21 Merge branch 'port-from-env' |\ | * 1ad8c1b Read the port from PORT * | 5718881 Log when the server starts |/ * 38d1d9b Add package.json and .gitignore * f2a3cfe Add the first server git branch -d port-from-env Deleted branch port-from-env (was 1ad8c1b).
The merge commit a429d21 has two parents, which is where the graph joins again. --no-edit accepts Git's default message. The branch has done its job, so git branch -d deletes it; its commits stay in the history. If a merge gets confusing, git merge --abort puts everything back as it was before the merge.
If you commit a secret
It happens: .gitignore was missing, or someone forced a file in. Here the mistake is made on purpose with git add -f, which adds a file even though it is ignored:
git add -f .env git commit -m "Add config" [main f3af9bb] Add config 1 file changed, 1 insertion(+) create mode 100644 .env git show --stat --oneline HEAD f3af9bb Add config .env | 1 + 1 file changed, 1 insertion(+)
REASON IT OUT
The secret is in a commit: what now?
The .env file with the real API key is now in a commit. Before reading on, think it through:
- You delete
.envand make a new commit. Is the key gone from the repository? - Who could already have a copy, if the commit was pushed to GitHub an hour ago? If it was never pushed?
- Which single action makes the leaked key useless, whoever has it?
Show the reasoning
Deleting the file in a new commit removes it from the latest snapshot only. Every earlier commit still contains it, and anyone with the repository can check out that commit.
If the commit never left your computer, nobody else has it, and you can simply take the commit back. Once it was pushed, you must assume it was copied: bots scan public repositories within minutes, and every teammate who pulled has it too.
Only rotating the secret (revoking the old key and issuing a new one) helps in every case. Cleaning the history is tidying up afterwards, not a fix.
What to do depends on one question: has the commit left your computer?
Not pushed yet
Undo the commit. git reset HEAD~1 moves the branch back one commit and keeps your files as they are, so .env is simply an ignored file again:
git reset HEAD~1 git status --short --ignored !! .env !! node_modules/ git log --oneline -- .env
!! marks ignored files. git log -- .env lists the commits that touched .env, and now prints nothing: no commit on main contains it.
Already pushed
Treat the secret as stolen. Bots scan public repositories for keys within minutes of a push. In this order:
- Rotate the secret first: change the password, revoke the key, create a new one. This is the only step that really protects you.
- Remove the file from Git and commit:
git rm --cached .env, then add it to.gitignore.--cachedstops tracking the file but keeps it on your disk. - The old value is still in the history. Rewriting history with a tool such as
git filter-repo, and force-pushing, removes it from the repository, but copies that others already pulled or forked still have it. That is why step 1 comes first.
GitHub secret scanning helps. GitHub checks pushed code for the formats of known secrets (cloud keys, payment keys, tokens) and alerts you, and many providers revoke a leaked key automatically. Push protection goes further and blocks a push that contains a recognised secret before it is published. Both are on by default for public repositories; check Settings → Code security on yours.
Scanners work by matching patterns. This toy version shows the idea; the real ones know hundreds of formats:
const patterns = [
{ name: "AWS access key id", regex: /\bAKIA[0-9A-Z]{16}\b/ },
{ name: "GitHub token", regex: /\bghp_[A-Za-z0-9]{36}\b/ },
{ name: "URL with a password", regex: /\/\/[^/\s:@]+:[^/\s@]+@/ },
];
function scan(fileName, text) {
const found = [];
text.split("\n").forEach((line, i) => {
for (const p of patterns) {
if (p.regex.test(line)) found.push(`${fileName}:${i + 1} looks like a ${p.name}`);
}
});
return found;
}
const env = "PORT=3000\nDATABASE_URL=postgres://postgres:hunter2@db:5432/tasks";
console.log(scan(".env", env));
console.log(scan("config.js", 'const url = process.env.DATABASE_URL;'));
node scan.js and of the browser terminal[ '.env:2 looks like a URL with a password' ] []
The .env text (with an obviously fake password) is flagged on line 2. The code that reads the value from process.env is clean, because it contains no secret at all. That is the pattern to follow.
GitHub: remotes, push, pull
A remote is another copy of the repository, usually on GitHub, that you send commits to and get commits from. To publish the Task API:
- Create an account on
github.com, then click New repository. Name ittask-api, make it private while you learn, and do not add a README (you have one). - GitHub shows the repository's URL. Connect your local repository to it:
git remote add origin https://github.com/your-username/task-api.git git remote -v origin https://github.com/your-username/task-api.git (fetch) origin https://github.com/your-username/task-api.git (push)
origin is the usual name for your main remote. Replace your-username with your GitHub name. Then:
git push -u origin mainsends yourmainbranch and its commits to GitHub.-uremembers the link, so later a plaingit pushis enough. The first time, Git asks you to log in; GitHub accepts a browser login through Git Credential Manager, a personal access token, or an SSH key, never your account password.git pullfetches the commits others pushed and merges them into your branch. Pull before you start work each day.git clone <url>copies a whole repository from GitHub onto a new computer, with its full history. Then runnpm install, becausenode_modulesis not in the repository, and create your own.env.
Pull requests
Teams do not push straight to main. They use pull requests (PRs), GitHub's way to propose, review and then merge a branch:
- Create a branch and commit your work on it:
git switch -c done-filter. - Push the branch:
git push -u origin done-filter. - On GitHub, click Compare & pull request. Describe what changed and why.
- Teammates read the diff and comment. Automated checks, such as a linter and your tests, run on it (JavaScript tooling sets up the check script, and Testing fundamentals, later in the academy, writes the tests). You fix things with more commits on the same branch; the PR updates by itself.
- When it is approved and the checks pass, click Merge. GitHub creates the merge on
main. - Locally,
git switch main,git pull, and delete the old branch.
A conflict can happen in a PR too, when main changed the same lines. You resolve it exactly as above: merge main into your branch, fix the markers, commit and push.
Practice
TRY IT YOURSELF
A branch with a clean merge
In your task-api repository, create a branch health, add a new file health.js, commit it, switch back to main and merge. Why is there no conflict, and what does git log --oneline --graph show?
Work it out first, on paper or in your head. Then use the hints, and compare with the solution.
HINT 1
A conflict only happens when both sides changed the same lines of the same file. Did anything else touch health.js, or anything at all, on main while you worked?
HINT 2
When the branch you merge is simply ahead of main, with nothing new on main's side, Git does not need to combine two histories at all — look up what it does instead in Branches and merging.
SOLUTION
Run git switch -c health, create the file, git add health.js, git commit -m "Add health check", git switch main, git merge health. There is no conflict because nobody else changed the same lines. If main got no new commits in the meantime, Git just moves main forward to your commit. That is a fast-forward merge, and the graph stays a straight line with no merge commit.
TRY IT YOURSELF
What goes in .gitignore?
Which of these belong in .gitignore for a Node.js project: node_modules/, package.json, package-lock.json, .env, .env.example, dist/ (compiled output), *.log, src/?
Work it out first, on paper or in your head. Then use the hints, and compare with the solution.
HINT 1
Ask, for each one: could another developer regenerate this file from what is committed, or does it hold something secret? Those are the ones to leave out.
HINT 2
node_modules/ and dist/ are rebuilt by npm install and your build step; .env is secret; *.log is a byproduct of running the program. Everything else is source or the exact record needed to reproduce an install.
SOLUTION
Ignore node_modules/, .env, dist/ and *.log: they are generated, secret or local. Commit package.json and package-lock.json (they let anyone reinstall the exact same packages), .env.example (names only, no values) and of course src/.
TRY IT YOURSELF
Extend the scanner
Add a pattern to scan.js for lines like SECRET=... or API_KEY=... with a value of 16 or more characters, and test it on a line with a long value and on API_KEY= with no value.
Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.
HINT 1
The variable name can have letters before or after the word that matters: [A-Z_]*(SECRET|KEY|TOKEN|PASSWORD)[A-Z_]*. The value needs at least 16 non-space characters: \S{16,}.
HINT 2
/^\s*[A-Z_]*(SECRET|KEY|TOKEN|PASSWORD)[A-Z_]*\s*=\s*\S{16,}/
SOLUTION
const assignment = /^\s*[A-Z_]*(SECRET|KEY|TOKEN|PASSWORD)[A-Z_]*\s*=\s*\S{16,}/;
for (const line of ["API_KEY=0123456789abcdef0123", "API_KEY=", "PORT=3000"]) {
console.log(JSON.stringify(line), assignment.test(line));
}
node scan-env.js and of the browser terminal"API_KEY=0123456789abcdef0123" true "API_KEY=" false "PORT=3000" false
An empty API_KEY=, as in a .env.example file, is not flagged, which is what you want.
Recap
- Git records snapshots called commits:
git addstages,git commit -mrecords,git statusandgit logshow where you are. - Branches hold separate lines of work.
git mergejoins them. A conflict shows both versions between markers: edit,git add, commit. - Put
node_modules/and.envin.gitignorebefore your firstgit add .. Read secrets fromprocess.env. - A committed secret that was pushed is leaked: rotate it first, then clean up. GitHub secret scanning and push protection help catch it.
git remote add origin,git push,git pullandgit cloneconnect you with GitHub. Teams merge through reviewed pull requests.
Next: Professional Git, where you work the way a team does: issues, pull requests that get reviewed, merge versus rebase, releases and changelogs.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.