LEVEL 4 · LESSON 16 OF 21

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.

  • 40 min to read and try
  • You need: npm and packages and Build a plain Node.js Task API, with Git installed on your computer
  • You build: A Git repository for the Task API with a clean history, a .gitignore that protects your secrets, and a GitHub remote
Test yourself

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:

Terminal on your computer
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:

Terminal on your computer
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:

Terminal on your computer
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 with git bisect.

.gitignore: node_modules and .env

Two things must never go into a repository:

  • node_modules/: thousands of files that npm install recreates from package.json and package-lock.json at 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:

Terminal on your computer
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:

Terminal on your computer
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:

config.jsNode.js only
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);
}
Output of node config.js
Missing 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:

Terminal on your computer
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:

Terminal on your computer
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:

Terminal on your computer
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 <<<<<<< HEAD to =======: 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:

Terminal on your computer
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:

Terminal on your computer
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 .env and 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:

Terminal on your computer
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:

  1. Rotate the secret first: change the password, revoke the key, create a new one. This is the only step that really protects you.
  2. Remove the file from Git and commit: git rm --cached .env, then add it to .gitignore. --cached stops tracking the file but keeps it on your disk.
  3. 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:

scan.js
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;'));
Output of 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:

  1. Create an account on github.com, then click New repository. Name it task-api, make it private while you learn, and do not add a README (you have one).
  2. GitHub shows the repository's URL. Connect your local repository to it:
Terminal on your computer
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 main sends your main branch and its commits to GitHub. -u remembers the link, so later a plain git push is 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 pull fetches 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 run npm install, because node_modules is 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:

  1. Create a branch and commit your work on it: git switch -c done-filter.
  2. Push the branch: git push -u origin done-filter.
  3. On GitHub, click Compare & pull request. Describe what changed and why.
  4. 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.
  5. When it is approved and the checks pass, click Merge. GitHub creates the merge on main.
  6. 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.

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.

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.

Recap

  • Git records snapshots called commits: git add stages, git commit -m records, git status and git log show where you are.
  • Branches hold separate lines of work. git merge joins them. A conflict shows both versions between markers: edit, git add, commit.
  • Put node_modules/ and .env in .gitignore before your first git add .. Read secrets from process.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 pull and git clone connect 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.