10 · Project — CLI Tool with Git Hook Integration¶
The Level 3 capstone: a multi-subcommand CLI tool, taskctl, that manages a
plain-text task list — plus a git pre-commit hook that uses it to block
commits containing unfinished TODO-tagged tasks.
What you'll build¶
taskctl— a single script with subcommands (add,list,done,rm), proper argument parsing, and a--helpfor each subcommand- Tasks stored one-per-line in a plain text file, so the tool composes
cleanly with
grep/awk/sort - A
pre-commitgit hook that runstaskctl list --pendingscoped to staged files and blocks the commit if any staged file contains an unresolvedTODO(taskctl)marker without a matching open task
Project layout¶
taskctl_project/
bin/taskctl
.git/hooks/pre-commit (installed by install-hook.sh)
install-hook.sh
tasks.txt (created on first `taskctl add`)
bin/taskctl¶
#!/usr/bin/env bash
# taskctl — a tiny CLI task manager with subcommands
set -euo pipefail
TASKCTL_FILE="${TASKCTL_FILE:-./tasks.txt}"
usage() {
cat <<'EOF'
Usage: taskctl <command> [args]
Commands:
add <description> add a new pending task
list [--pending] list all tasks (or only pending ones)
done <id> mark a task done
rm <id> remove a task
help show this help
Environment:
TASKCTL_FILE path to the task store (default: ./tasks.txt)
EOF
}
die() {
echo "taskctl: error: $1" >&2
exit 1
}
ensure_store() {
[[ -f "$TASKCTL_FILE" ]] || : > "$TASKCTL_FILE"
}
next_id() {
# id is just "1 + highest existing id"; 0 if the store is empty
awk -F'|' 'BEGIN{max=0} {if ($1+0>max) max=$1+0} END{print max+1}' "$TASKCTL_FILE"
}
cmd_add() {
local description="$*"
[[ -n "$description" ]] || die "add requires a description"
ensure_store
local id
id=$(next_id)
# format: id|status|description
echo "${id}|pending|${description}" >> "$TASKCTL_FILE"
echo "added task #$id: $description"
}
cmd_list() {
ensure_store
local pending_only="${1:-}"
while IFS='|' read -r id status description; do
[[ -z "$id" ]] && continue
if [[ "$pending_only" == "--pending" && "$status" != "pending" ]]; then
continue
fi
printf '#%-4s [%-7s] %s\n' "$id" "$status" "$description"
done < "$TASKCTL_FILE"
}
cmd_done() {
local id="${1:-}"
[[ -n "$id" ]] || die "done requires a task id"
ensure_store
grep -q "^${id}|" "$TASKCTL_FILE" || die "no task with id $id"
local tmp
tmp=$(mktemp)
awk -F'|' -v id="$id" 'BEGIN{OFS="|"} { if ($1==id) $2="done"; print }' \
"$TASKCTL_FILE" > "$tmp"
mv "$tmp" "$TASKCTL_FILE"
echo "marked #$id done"
}
cmd_rm() {
local id="${1:-}"
[[ -n "$id" ]] || die "rm requires a task id"
ensure_store
grep -q "^${id}|" "$TASKCTL_FILE" || die "no task with id $id"
local tmp
tmp=$(mktemp)
grep -v "^${id}|" "$TASKCTL_FILE" > "$tmp" || true
mv "$tmp" "$TASKCTL_FILE"
echo "removed #$id"
}
main() {
local command="${1:-help}"
shift || true
case "$command" in
add) cmd_add "$@" ;;
list) cmd_list "$@" ;;
done) cmd_done "$@" ;;
rm) cmd_rm "$@" ;;
help|-h|--help) usage ;;
*) die "unknown command '$command' (see 'taskctl help')" ;;
esac
}
main "$@"
Running it¶
chmod +x bin/taskctl
export PATH="$PWD/bin:$PATH"
taskctl add "write the networking module"
# added task #1: write the networking module
taskctl add "review PR #42"
# added task #2: review PR #42
taskctl list
# #1 [pending] write the networking module
# #2 [pending] review PR #42
taskctl done 1
# marked #1 done
taskctl list --pending
# #2 [pending] review PR #42
taskctl rm 2
# removed #2
The git hook: blocking commits with open TODO markers¶
The idea: any staged file can carry a TODO(taskctl): comment referencing a
task description. The pre-commit hook scans staged files for these markers
and cross-checks them against taskctl list --pending — if a marker's text
doesn't match any pending task, it's either stale or was never tracked, and
the commit is blocked until it's resolved or properly logged.
#!/usr/bin/env bash
# .git/hooks/pre-commit — block commits with untracked TODO(taskctl) markers
set -euo pipefail
repo_root=$(git rev-parse --show-toplevel)
export TASKCTL_FILE="$repo_root/tasks.txt"
staged_files=$(git diff --cached --name-only --diff-filter=ACM)
[[ -z "$staged_files" ]] && exit 0
pending_tasks=$("$repo_root/bin/taskctl" list --pending 2>/dev/null || true)
blocked=0
while IFS= read -r file; do
[[ -f "$file" ]] || continue
while IFS=: read -r lineno marker; do
[[ -z "$marker" ]] && continue
text=$(echo "$marker" | sed -E 's/.*TODO\(taskctl\):\s*//')
if ! echo "$pending_tasks" | grep -qF "$text"; then
echo "BLOCKED: $file:$lineno has an untracked TODO(taskctl): $text"
echo " -> run: taskctl add \"$text\""
blocked=1
fi
done < <(grep -n 'TODO(taskctl):' "$file" || true)
done <<< "$staged_files"
if [[ "$blocked" -eq 1 ]]; then
echo ""
echo "commit blocked: resolve or register the TODO(taskctl) markers above."
exit 1
fi
exit 0
install-hook.sh¶
#!/usr/bin/env bash
# install-hook.sh — copies pre-commit into .git/hooks and makes it executable
set -euo pipefail
repo_root=$(git rev-parse --show-toplevel)
cp hooks/pre-commit "$repo_root/.git/hooks/pre-commit"
chmod +x "$repo_root/.git/hooks/pre-commit"
echo "pre-commit hook installed."
Trying the full flow¶
./install-hook.sh
taskctl add "finish the auth module"
echo '# TODO(taskctl): finish the auth module' >> auth.sh
git add auth.sh
git commit -m "wip: auth module"
# passes — the marker matches a pending task
echo '# TODO(taskctl): add rate limiting' >> auth.sh
git add auth.sh
git commit -m "wip: more auth"
# BLOCKED: auth.sh:2 has an untracked TODO(taskctl): add rate limiting
# -> run: taskctl add "add rate limiting"
How earlier modules show up here¶
| Module | Where it's used |
|---|---|
| Subcommand dispatch | case "$command" in ... esac in main() |
| awk field rewriting | cmd_done's in-place status update |
| Signal-safe temp files | mktemp + mv instead of editing in place |
| Git hooks (03/06) | .git/hooks/pre-commit, git diff --cached |
| Process substitution (02) | < <(grep -n ... "$file") |
How It Actually Works¶
A dispatch-style CLI (tool.sh subcommand args...) relies on the shell
mechanics covered throughout this course working together: the positional
parameters ($1, $2, "$@") are populated the moment the script's own
process is exec'd by whatever forked it, argument parsing with getopts
or a manual case loop consumes them by re-assigning $@ via shift
(which mutates bash's internal positional-parameter array in place, not a
copy), and each subcommand is typically dispatched either as a function
call (no fork — same process, same variable scope minus locals) or by
execing a separate script file for that subcommand (a real process
replacement, useful for plugin-style tools that want independent
subprocesses).
getopts maintains its scanning position across calls in the OPTIND
variable specifically because option parsing has to be resumable — each
invocation of getopts inside its while loop picks up exactly where the
last one left off in $@, which is internal state bash tracks per-shell
(not per-call), which is why forgetting to reset OPTIND when reusing
getopts across multiple argument-parsing passes in the same shell session
is a classic bug.
Error handling that exits with distinct codes per failure type works because, as covered earlier, the kernel only ever sees an 8-bit integer at process exit — a CLI tool's "exit code contract" with its callers (scripts, CI systems, other tools) is entirely a userspace convention layered on top of that one raw integer, with no enforcement beyond documentation and discipline.
Stretch goals¶
- Add a
taskctl edit <id> <new description>subcommand. - Make the hook also run on
git commit --amendand reject empty descriptions. - Package
taskctlusing Module 08'sinstall.sh+ man page pattern so it can be installed once and reused across every repo on the machine. - Add a
taskctl.jsonalternate storage backend and usejq(Module 09) to read/write it, behind aTASKCTL_FORMAT=jsonenvironment variable.