Session 1 ended with a file that runs: a shebang, one variable, chmod +x, and ./logsum.sh.
Today that file learns to take an argument, refuse bad input and report a number — and then
it learns to have a history.
On Windows you must be in WSL Ubuntu or Git Bash. Never PowerShell, never CMD.
Rebuild it. Safe to run twice; it creates everything it needs.
mkdir -p /tmp/lab && cd /tmp/lab
# Ignore the [ and the 2>/dev/null for now — they are minutes 10 and 36.
[ -s app.log ] || curl -fsS -o app.log http://localhost:8899/files/app-2026-08-01.log \
|| cp /Volumes/DATAEKO/app-2026-08-01.log app.log 2>/dev/null \
|| cp "/media/$USER/DATAEKO/app-2026-08-01.log" app.log 2>/dev/null \
|| cp /mnt/e/app-2026-08-01.log app.log 2>/dev/null \
|| echo "come and take app-2026-08-01.log off the USB stick — save it here as app.log"
cat > logsum.sh <<'EOF'
#!/usr/bin/env bash
logfile="app.log"
echo "reading $logfile"
EOF
chmod +x logsum.sh
./logsum.sh
ls -l app.log
git add, branches, merges, a real conflict, a remote.
Half the session.By the end you will write a script that takes a filename as an argument and refuses bad input with an exit code — and then commit it, branch it, resolve a real conflict and push it.
$ is how you get it backAssignment is three parts and no spaces: the name, the =, the value. The shell
splits every line on spaces before it does anything else, so name = "asha" makes name
the command and dies with name: command not found.
Reading it back needs the $. ${name} is the same thing with a fence around the name,
for when the next character would otherwise be swallowed into it.
name="asha"
echo "$names" # nothing. there is no variable called names
echo "${name}s" # ashas
f="my report.txt"; rm $f # rm: my: No such file or directory <- two words
rm "$f" # correct: one word
Quote every expansion. Unquoted, the shell splits the value on spaces all over again and hands the pieces to the command as separate arguments.
$( ) runs a command and hands you what it printedtotal=$(wc -l < "$logfile") runs wc, catches everything it wrote to its results
channel, and puts that text in total. Anywhere a value can go, $( ... ) can go
instead — that is the join between typing commands and writing scripts.
BSD wc pads its number, so macOS prints 5000 where Ubuntu prints 5000.
${total// /} is the ${ } fence from the last slide with a substitution inside: $total
with every space removed. Every machine in the room then agrees.
printf prints a template you control: %-6s is a string left-aligned six wide, %5d a
number right-aligned five wide, and \n is the newline printf does not add for you.
total=$(wc -l < app.log) # < feeds the file in. redirection is minute 32
total=${total// /} # drop BSD wc's padding
printf ' %-6s %5d\n' "ERROR" "$total"
ERROR 5000
if runs a command and looks at the number it left behindif does not evaluate an expression. It runs a command and branches on that command's
exit code: zero takes the then path, anything else takes else. fi is if backwards
and closes the block.
And [ is not syntax. It is a program with a man page: ls -l /bin/[ is a 101 KB
executable, the same file as /bin/test. It reads its arguments, exits 0 or 1, and
insists that ] is its last argument. That is why the spaces are compulsory —
[-f app.log] fails with command not found, exit 127, the same 127 you met in
Session 1.
if [ -f "$logfile" ]; then
echo "found it"
else
echo "no such file: $logfile" >&2 # deliberate: complaints channel, minute 32
fi
= for numbers| test | true when |
|---|---|
[ -f "$p" ] | $p exists and is a plain file |
[ -d "$p" ] | $p exists and is a directory |
[ -z "$s" ] | the string has zero length — empty, or unset |
[ -n "$s" ] | the string has something in it |
[ "$a" = "$b" ] · != | the two strings match, character for character |
[ "$a" -eq "$b" ] · -ne -lt -gt -le -ge | the two numbers compare |
String and numeric comparison are genuinely different operators. [ "10" = "10.0" ] is
false; [ 10 -eq 10 ] is true; and [ "9" \> "10" ] is true, because alphabetically
9 sorts after 1. Put ! in front of any test to invert it: [ ! -f "$f" ].
Quote both sides. With $a empty and unquoted, [ $a = "x" ] reaches test as
[ = "x" ] and bash says [: =: unary operator expected.
for name in LIST; do ... done. The list is just words separated by spaces, and name is
an ordinary variable holding each word in turn. The ; before do is not decoration —
do has to begin a command, so it needs a semicolon or a newline in front of it.
Leave the ; out and bash says syntax error near unexpected token. Leave done out and
it says syntax error: unexpected end of file — and blames the last line of your file,
not the loop. To walk the lines of a file instead of words, feed it into while read
(IFS= keeps leading spaces, -r stops backslashes disappearing).
for level in FATAL ERROR WARN INFO; do
printf ' %-6s %5d\n' "$level" "$(grep -c "\"level\":\"$level\"" app.log)"
done
while IFS= read -r svc; do echo "checking $svc"; done < services.txt
$1 is whatever the caller typed firstRun ./logsum.sh app.log ERROR and the shell hands your script the words as numbered
variables. $0 is the name it was invoked as, $1 the first argument, $2 the second.
$# is how many arrived — zero if none did. $@ is all of them at once, and written
"$@" each one stays a single word even if it contains spaces.
$0 = ./logsum.sh $1 = app.log $2 = ERROR $# = 2 $@ = app.log ERROR
A script with logfile="app.log" hard-coded works in one directory, on one machine, on
one day's file. logfile="$1" works on any file, can be called by another script, and can
be run by cron against tomorrow's log without anybody editing it. A tool takes its input
as an argument.
When a program finishes it hands back one number, 0 to 255, and the shell keeps the most
recent one in $?. Zero means success. Every other value means one specific failure
the program chose: grep exits 1 when it matched nothing. Session 1's 126 and 127 come
from the shell itself — found but not executable, and never found at all.
exit N is how your script sets its own, and nothing after it runs. Use 1 for "you called
me wrong", any number below 126 for your own failures, and write down what each one means.
Nobody reads your error message at 3am. Cron and CI read the number.
grep -q FATAL app.log; echo $? # 0 — it matched
grep -q NOPE app.log; echo $? # 1 — it matched nothing
Six ideas, one file — and every one of them is from the last twenty minutes.
mkdir -p /tmp/lab && cd /tmp/lab
[ -s app.log ] || curl -fsS -o app.log http://localhost:8899/files/app-2026-08-01.log
cat > logsum.sh <<'EOF'
#!/usr/bin/env bash
logfile="$1"
if [ -z "$logfile" ]; then echo "usage: $0 <logfile>" >&2; exit 1; fi
if [ ! -f "$logfile" ]; then echo "error: no such file: $logfile" >&2; exit 2; fi
total=$(wc -l < "$logfile")
total=${total// /}
echo "file: $logfile"
echo "lines: $total"
echo
echo "count by level:"
for level in FATAL ERROR WARN INFO; do
n=$(grep -c "\"level\":\"$level\"" "$logfile")
printf ' %-6s %5d\n' "$level" "$n"
done
EOF
chmod +x logsum.sh
cd /tmp/lab
./logsum.sh; echo "exit=$?" # no argument at all
./logsum.sh app.log; echo "exit=$?"
The first run refuses and prints exit=1. The second must print exactly this:
file: app.log
lines: 5000
count by level:
FATAL 17
ERROR 263
WARN 651
INFO 3598
Hands up if any of your four numbers is different. Stretch: discover the levels from the
file instead of hard-coding four, and exit 3 if there is any FATAL.
Every process is born with three connections and the kernel numbers them. 0 is stdin, where input arrives. 1 is stdout, where results go. 2 is stderr, where complaints go. 1 and 2 both point at your screen, which is why they look like one thing. They are not.
> points channel 1 at a file and empties that file before the command even runs;
>> appends instead. 2> moves channel 2. 2>&1 means "point 2 wherever 1 points right
now", so > f 2>&1 catches both, while 2>&1 > f sends 2 to the screen — where 1 still
was — and only then moves 1. /dev/null discards everything written to it.
ls real.txt nope.txt > out.txt # results -> file, error on screen
ls real.txt nope.txt 2> err.txt # error -> file, results on screen
ls real.txt nope.txt > all.txt 2>&1 # both -> the file
ls real.txt nope.txt 2>&1 > f2.txt # WRONG ORDER: only results reach it
ls real.txt nope.txt 2>/dev/null # complaints discarded
echo "one more line" >> all.txt # >> appends. > would have emptied it
a | b starts both programs at once and wires them together: whatever a writes to its
results channel arrives as b's input, with no file on disk in between. Channel 2 is not
in the wire — errors run past every stage onto your screen. Merge with 2>&1 to carry
them along.
grep -o '"level":"[A-Z]*"' app.log | sort | uniq -c | sort -rn
3598 "level":"INFO"
651 "level":"WARN"
263 "level":"ERROR"
17 "level":"FATAL"
sort | uniq -c | sort -rn answers "what is most common in this pile", for any pile.
uniq only collapses adjacent duplicates, so the first sort is not optional.
mkdir -p /tmp/lab && cd /tmp/lab && touch real.txt
[ -s app.log ] || curl -fsS -o app.log http://localhost:8899/files/app-2026-08-01.log
ls real.txt nope.txt | grep -c "" # 1 — the error was never in the pipe
ls real.txt nope.txt 2>&1 | grep -c "" # 2 — now it is
grep -o '"service":"[a-z-]*"' app.log | sort | uniq -c | sort -rn | head -3
logsum-v2-final-REAL.sh is version control, done badly. What that folder cannot tell you:
which line changed and why, who changed it, what the file looked like last Tuesday, and
what to do when two people edited it the same afternoon.
A version control system stores every state a project has ever been in, each labelled
with an author, a time and a message, and can reconstruct or compare any two of them on
demand. Git does it with no server involved: the .git folder inside your project is the
whole database, so committing, branching and reading history work on a plane.
Git versions the project, not the file. One commit is the state of everything at once.
tree d4e01edf1e8aa72182ed9449e7d12b5e4df8b201
parent 2566e43f70443c0aa178cb1befb40ed88527a4c2
author you <you@example.com> 1785750515 +0530
committer you <you@example.com> 1785750515 +0530
second
That is the whole object. tree is every file in the project at that moment — a commit
stores a snapshot, never a diff; git diff computes differences on demand by comparing
two trees. parent is the commit before it, named.
Nobody chose those forty characters. Git prepends a type and a byte count to the content and takes the SHA-1 of that, and the digest is the name — so identical content is identically named on every machine, forever. And because a commit's bytes contain its parent's name, altering anything old changes its name, which changes its child's bytes, which changes every name after it.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf peek && git init -q peek && cd peek
printf 'hello\n' > a.txt && git add . && git commit -qm "first" # what git add does: next slide
printf 'hello\nworld\n' > a.txt && git commit -qam "second"
git cat-file -p HEAD
printf 'hello\n' | git hash-object --stdin # ce013625030ba8dba906f756967f9e9ca394464a
printf 'blob 6\0hello\n' | shasum # the same 40 characters, by hand
git add is the door between two of themYour project has a working tree (the files on disk you edit), a staging area (a list of exactly what your next commit will contain) and committed history (everything already saved).
git add f.txt copies the current contents of f.txt from the working tree into the
staging area. Not "tell Git the file exists", not "upload it" — copy, now, this version.
Edit the file afterwards and the staged copy does not follow.
It exists because a commit is composed, not photographed: git add -p stages some
hunks and not others, so a reviewer reads three small commits instead of one blob.
v3 working tree what is on your disk right now
v2 staging area what your next commit would contain
v1 last commit what history currently says
git status --short prints two letters nobody ever explains: left column is staging area
versus last commit, right column is disk versus staging area. So MM means changed in
both — and the conflict in twenty minutes prints UU in exactly the same pair.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf areas && git init -q areas && cd areas
printf 'v1\n' > f.txt && git add . && git commit -qm c1
printf 'v2\n' > f.txt && git add f.txt # v2 -> staging area
printf 'v3\n' > f.txt # v3 -> working tree only
cat f.txt # v3 working tree
git cat-file -p :f.txt # v2 staging area
git cat-file -p HEAD:f.txt # v1 last commit
git status --short # MM f.txt
| command | what it actually does |
|---|---|
git init | creates .git/ here. That folder is the repository |
git status | untracked, staged, modified — type it between all the others |
git add F | copies F's current contents into the staging area |
git commit -m "…" | turns the staging area into a commit, with that message |
git log --oneline | one line per commit, newest first: hash, then message |
Nothing here touches a network. git init is instant, needs no account, and the
repository it makes is complete on its own.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf logsum && mkdir logsum && cd logsum
cp /tmp/lab/logsum.sh . 2>/dev/null || printf '#!/usr/bin/env bash\necho hi\n' > logsum.sh
mkdir -p data && { cp /tmp/lab/app.log data/ 2>/dev/null || printf 'a log\n' > data/app.log; }
git init -q && git branch -M main # -M so the whole room is on main. branches: minute 62
git status --short # ?? data/ ?? logsum.sh <- Git has never seen these
git add logsum.sh data
git status --short # A data/app.log A logsum.sh <- now staged
git commit -m "Add logsum and the log file it reads"
printf '# logsum\n\nUsage: ./logsum.sh <logfile>\n' > README.md && git add README.md
git commit -m "Document usage in the README"
git log --oneline
.gitignore that keeps junk out of itWrite the subject line in the imperative, under 72 characters, saying what the commit
changes: Add exit code 3 for logs with zero events, never update or final. Read it
back as "if applied, this commit will…" and it either makes sense or it does not.
A clean history for a few hours of work is not a rebased masterpiece. It is several small commits made as you go, each doing one thing, with a branch for one chunk of it.
.gitignore is one pattern per line, lives at the top of the repo and is itself committed.
It is for what your work produces — generated reports, .DS_Store, editor swap files —
never for the input your reader needs. *.log would ignore the very data your script reads.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf hygiene && git init -q hygiene && cd hygiene
git branch -M main
mkdir -p data && printf 'a log line\n' > data/app.log # input. it MUST be committed
printf '#!/usr/bin/env bash\necho hi\n' > logsum.sh && chmod +x logsum.sh
touch report.txt .DS_Store # output and junk. neither belongs
printf 'report.txt\n.DS_Store\n*.tmp\n' > .gitignore
git add . && git commit -qm "Add logsum skeleton and the log file under data/"
git status --ignored --short # !! .DS_Store !! report.txt <- seen, and skipped
git ls-files # .gitignore data/app.log logsum.sh <- no junk, data kept
git ls-files -s logsum.sh # 100755 … <- the executable bit is committed too
git diff always compares two of the three areas — you pick whichPlain git diff compares disk against the staging area: what you have changed and not
staged yet. git diff --staged compares the staging area against the last commit:
precisely what a git commit right now would record.
Read git diff --staged before every commit. It is the cheapest code review you will
ever get, and it is how you catch the debug print you left in.
$ git diff $ git diff --staged
-v2 -v1
+v3 +v2
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf diffs && git init -q diffs && cd diffs
printf 'v1\n' > f.txt && git add . && git commit -qm c1
printf 'v2\n' > f.txt && git add f.txt && printf 'v3\n' > f.txt
git diff # disk vs staging area: -v2 +v3
git diff --staged # staging area vs commit: -v1 +v2
git log prints commits newest first, and the flags narrow it until it answers a question.
--oneline collapses each commit to a hash and a subject. -n 2 limits how many you get.
-- f.txt limits it to the commits that touched one path. -p attaches each commit's diff,
which turns the log into the story of that file. --graph draws one column per line of
history, so once there is a branch you can see the fork instead of inferring it.
git show <thing> prints one commit whole — author, date, message, diff — and <thing> can
be a hash, HEAD, HEAD~2 or a branch name. In a repository you did not write, the first
thing to type is git log --oneline.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf hist && git init -q hist && cd hist
git branch -M main
printf 'v1\n' > f.txt && git add . && git commit -qm "Add f with the first value"
printf 'v1\nv2\n' > f.txt && git commit -qam "Add the second value"
printf '# notes\n' > README.md && git add . && git commit -qm "Start a README"
git log --oneline # three commits, one line each, newest first
git log --oneline -n 2 # just the two most recent
git log --oneline -- f.txt # only the two that touched f.txt. no README commit
git log -p -1 -- f.txt # the last commit that touched f.txt, with its diff
git show HEAD~2 # one commit in full: author, date, message, diff
.git/refs/heads/main contains forty hex characters and a newline — the name of the
commit at the tip of that branch. That is the entire branch. Creating one writes 41 bytes
and copies no code at all.
.git/HEAD holds the line ref: refs/heads/main, and that is how your shell knows which
branch you are on; switching rewrites that one line. Committing moves the current
branch's 41 bytes forward — so a branch is not a copy of your work, it is a bookmark that
follows you as you work.
git branch lists them, git switch -c feat creates one and moves to it, git switch main
goes back. A branch costs 41 bytes, so make one.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf brlab && git init -q brlab && cd brlab
printf 'a\n' > f && git add . && git commit -qm base && git branch -M main
git switch -c feat
cat .git/HEAD # ref: refs/heads/feat
cat .git/refs/heads/main .git/refs/heads/feat
wc -c .git/refs/heads/main # 41
diff .git/refs/heads/main .git/refs/heads/feat \
&& echo "byte-identical: two branches, one commit"
git merge feat asks one question first: has main moved since feat branched off?
If it has not, everything main has is already inside feat: Git rewrites main's 41 bytes
to point at feat's tip and stops. A fast-forward makes no commit at all.
If both branches have commits the other does not, Git builds the combined tree and records
it as a merge commit — the only kind of commit with two parent lines.
Updating 262ac3a..211c87e <- fast-forward: no commit made
Fast-forward
… 1 file changed, 1 insertion(+)
Merge made by the 'ort' strategy. <- merge commit: two parents
… 1 file changed, 1 insertion(+)
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf myff && git init -q myff && cd myff
printf 'a\n' > f && git add . && git commit -qm base && git branch -M main
git switch -qc feat && printf 'b\n' > g && git add . && git commit -qm feat
git switch -q main && git merge feat && git log --oneline --graph # linear. no merge commit
git reset -q --hard HEAD~1
git merge --no-ff --no-edit feat && git log --oneline --graph # a fork and a join
git cat-file -p HEAD | grep -c '^parent' # 2
Two branches changed the same lines of the same file from the same starting point. Git
will not guess, so it stops mid-merge and edits the file on disk: your side, then
=======, then theirs, fenced by <<<<<<< and >>>>>>>.
service: ingest
replicas: 1
<<<<<<< HEAD
owner: alice
=======
owner: bob
>>>>>>> bob
Those are ordinary characters in an ordinary file. Nothing is locked and nothing is
lost: git ls-files -u lists three entries for that one path — stage 1 the ancestor, 2
yours, 3 theirs — and git cat-file -p :1:deploy.yml prints any of them.
Resolving means deleting the three marker lines and leaving the text you want; you may
pick neither side. Then git add — during a conflict git add means "I have resolved
this" — and git commit.
Look at the file before you touch it. Everything here builds its own repository.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf myclash && git init -q myclash && cd myclash
printf 'owner: TBD\n' > deploy.yml && git add . && git commit -qm base && git branch -M main
git switch -qc alice && printf 'owner: alice\n' > deploy.yml && git commit -qam alice
git switch -q main && git switch -qc bob && printf 'owner: bob\n' > deploy.yml && git commit -qam bob
git switch -q main && git merge alice && git merge bob
cat deploy.yml # LOOK AT IT before you change anything
git ls-files -u # three versions of one path
nano deploy.yml # delete the 3 marker lines, leave one owner. Ctrl-O Enter Ctrl-X
# trapped in an editor? Esc : q ! Enter — then run this line instead:
# printf 'owner: platform-team\n' > deploy.yml
git add deploy.yml
git ls-files -u # empty now. that is what "resolved" means
git commit -m "merge bob: owner is platform-team"
git cat-file -p HEAD | grep -c '^parent' # 2
Read your resolved line out when I reach your desk. Stretch: resolve it again with
git checkout --ours deploy.yml, then work out why those two flags mean the opposite of
what you expect during a rebase.
origin is a nickname for a URLA remote is a name your repository stores for somewhere else the same repository
lives. origin is just the conventional name for the first one — nothing is special about
the word, and a repo can have several.
git remote add origin <url> records it, git remote -v prints what you have, and
git clone sets origin for you. git push -u origin main sends your commits there and
remembers the pairing, so every later push is a bare git push.
Git refuses a push that would make the remote forget somebody else's commit:
! [rejected] main -> main (fetch first). That is not a permissions problem — it means
someone pushed while you were working. Fetch, integrate, push again.
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf remotelab && mkdir remotelab && cd remotelab
git init -q --bare srv.git # this is "the server". a folder, no working tree
git clone srv.git me && cd me # warns that it is empty. it is. you just made it
printf 'hello\n' > README.md && git add . && git commit -qm initial && git branch -M main
git remote -v
git push -u origin main
fetch moves a pointer. pull also changes your files.git fetch downloads the commits you do not have and moves the read-only pointer
origin/main to wherever the remote's main now is. Your branch does not move and your
working tree does not change by a single byte. git status then tells you
Your branch is behind 'origin/main' by 1 commit.
git pull is git fetch followed by git merge origin/main in one word — and that
second half is what edits your files, and what occasionally hands you a merge commit or a
conflict you were not expecting.
The habit: fetch, then git log HEAD..origin/main to read what is coming, then merge.
mkdir -p /tmp/git-lab/remotelab && cd /tmp/git-lab/remotelab
[ -d srv.git ] || { git init -q --bare srv.git && git clone -q srv.git me && cd me \
&& printf 'hello\n' > README.md && git add . && git commit -qm initial \
&& git branch -M main && git push -q -u origin main && cd ..; }
rm -rf teammate && git clone -q srv.git teammate # somebody else's laptop
printf 'hello\nfrom them\n' > teammate/README.md
git -C teammate commit -qam "their edit" && git -C teammate push -q origin main
cd me
git fetch origin
cat README.md # UNCHANGED — fetch did not touch your disk
git status # behind 'origin/main' by 1 commit
git log --oneline HEAD..origin/main
git pull && cat README.md # now it changes
reset moves a pointer.git reset --hard HEAD~1 destroys nothing. It rewrites your branch's 41 bytes so they name
the previous commit. The commit you "lost" is still sitting in .git — it simply has no
branch pointing at it any more, so git log has no route to it.
git reflog is the log of every value HEAD has had in this clone — every commit,
switch, merge and reset, newest first. HEAD@{1} means "where HEAD was one move ago", and
it is a position, not a hash, which is why it is the safe thing to type.
0517b12 HEAD@{0}: reset: moving to HEAD~1
122c6bd HEAD@{1}: commit: c3 <- the "destroyed" commit
0517b12 HEAD@{2}: commit: c2
d21214a HEAD@{3}: commit (initial): c1
mkdir -p /tmp/git-lab && cd /tmp/git-lab && rm -rf undo && git init -q undo && cd undo
printf 'v1\n' > f.txt && git add . && git commit -qm c1
printf 'v2\n' > f.txt && git commit -qam c2
printf 'v3\n' > f.txt && git commit -qam c3
git reset --hard HEAD~1 # "destroy" the last commit
git log --oneline # it is gone
git reflog # it is not gone
git reset --hard 'HEAD@{1}' # bring it back
git log --oneline
git revert undoes in public, without rewriting historygit revert <commit> removes nothing. It creates a new commit that applies the exact
opposite change, so history gets longer rather than different — and every clone your
colleagues already have stays valid.
[main c671799] Revert "c3"
Date: Sat Aug 1 15:04:11 2026 +0530
1 file changed, 1 insertion(+), 1 deletion(-)
That is the whole distinction: reset moves a pointer backwards and pretends; revert
adds a commit and admits. Use revert on anything already pushed, shared or deployed, and
reset only on work nobody else has seen.
You can almost never lose work in Git — provided you committed it. A commit is a few hundred bytes and a branch is forty-one. They are the cheapest insurance in software.
Assignment 1 is out now, and the assignment page is the whole brief. Everything — what to build, how it is marked, when it is due — is there.