Git and GitHub
Lesson 12 — R Programming
Learning objectives
- Use the core Git commands and understand the three states
- Write commits that are useful six months later
- Work with branches and resolve merge conflicts
- Collaborate through pull requests and code review
- Run automated checks with GitHub Actions
- Apply version control to regulated study code
The mental model
Git tracks changes, not files. Three places a change can be:
Working directory -> Staging area -> Repository
(your edits) (git add) (git commit)
And, with a remote:
Repository -> Remote (GitHub)
(git push)
Almost every Git problem is a confusion about which of these a file is in. git status answers that question and you should run it constantly.
Setup
git config --global user.name "Ram Gaduputi"
git config --global user.email "ram@example.com"
git config --global init.defaultBranch main
git config --global pull.rebase falseFrom R:
usethis::use_git_config(user.name = "Ram Gaduputi", user.email = "ram@example.com")
usethis::git_sitrep() # diagnose the whole setup
usethis::create_github_token() # opens the GitHub PAT page
gitcreds::gitcreds_set() # store the tokenusethis::git_sitrep() is the single most useful command when Git and GitHub are not talking to each other — it checks the config, the credentials, the remote and the branch, and tells you which is wrong.
Daily commands
git status # what has changed, and where it is
git add file.R # stage one file
git add . # stage everything
git add -p # stage selected hunks interactively
git commit -m "Add study day derivation"
git commit --amend # fix the last commit (only if unpushed)
git log --oneline --graph --decorate --all
git diff # working directory vs staged
git diff --staged # staged vs last commit
git diff HEAD~1 # vs previous commit
git push
git pull
git fetch # get remote changes without merginggit add -p deserves a mention: it walks through each change and asks whether to stage it. It is how you commit one logical change when you have made three.
What to commit
# .gitignore
.Rproj.user
.Rhistory
.RData
.Ruserdata
*.Rproj.user
# Never commit patient data
data/raw/
*.sas7bdat
*.xpt
# Outputs are regenerable
outputs/
_site/
*.html
*.pdf
# renv library is large; the lockfile is what matters
renv/library/
renv/staging/
usethis::use_git_ignore(c("data/raw/", "*.sas7bdat"))| Commit | Do not commit |
|---|---|
Source code (.R, .qmd, .Rmd) |
Patient data, ever |
DESCRIPTION, NAMESPACE |
Credentials, .Renviron |
renv.lock |
renv/library/ |
| Specifications, README | Generated outputs |
| Tests and test fixtures | .Rproj.user, .Rhistory |
| Small synthetic data | Large binaries |
Removing a password in a later commit does not remove it from history. If a credential or patient data is ever committed, treat it as disclosed: rotate the credential and follow your organisation’s incident process. Rewriting history with git filter-repo or BFG is possible but does not help if the repository was pushed and cloned.
Prevention: .gitignore before the first commit, and usethis::git_vaccinate() to add the common offenders globally.
Commit messages
The message is documentation of why. The diff already shows what.
Bad:
update
fix
changes
asdf
Good:
Add treatment-emergent flag derivation to ADAE
Implements TRTEMFL per SAP section 9.2: events on or after
first dose and within 30 days of last dose are flagged Y.
Partial start dates impute to the earliest possible date.
Refs: JIRA ABC-142
Conventions worth adopting:
- Subject line under 50 characters, imperative mood (“Add”, not “Added”)
- Blank line, then body wrapped at 72 characters
- Body explains why, and any decision a reviewer would question
- Reference the issue or specification section
One logical change per commit. If the message needs the word “and”, it is probably two commits.
Branches
git branch # list
git switch -c feature/adae-flags # create and switch (modern)
git checkout -b feature/adae-flags # same, older syntax
git switch main
git merge feature/adae-flags
git branch -d feature/adae-flagsusethis::pr_init("adae-flags") # create branch, ready for a PR
usethis::pr_push() # push and open the PR page
usethis::pr_finish() # after merge: switch back and clean upA workable branching model for study code:
main always working; what produced the current deliverable
├── feature/adae-flags new derivation
├── fix/study-day-edge bug fix
└── release/dbl-1 frozen at database lock
Tag anything that produced a deliverable:
git tag -a v1.0-dbl1 -m "Database lock 1 — tables and listings delivered 2026-07-15"
git push --tagsThat tag is how you answer “what exactly produced this table?” two years later, during an inspection.
Merge conflicts
They happen when two branches change the same lines. Git marks them:
<<<<<<< HEAD
age_group = case_when(AGE < 65 ~ "<65", .default = ">=65")
=======
age_group = case_when(AGE < 18 ~ "<18", AGE < 65 ~ "18-64", .default = ">=65")
>>>>>>> feature/age-groupsResolve by editing the file to what it should be — often neither version verbatim — removing the markers, then:
git add conflicted-file.R
git commitTo abort and think again:
git merge --abortPrevention beats cure: small, frequent commits; short-lived branches; pull before you start work. A branch that lives for three weeks will conflict.
Undoing things
| Situation | Command |
|---|---|
| Discard unstaged changes to a file | git restore file.R |
| Unstage a file, keep the changes | git restore --staged file.R |
| Amend the last commit message | git commit --amend |
| Undo the last commit, keep changes staged | git reset --soft HEAD~1 |
| Undo the last commit, keep changes unstaged | git reset HEAD~1 |
| Undo the last commit, discard changes | git reset --hard HEAD~1 |
| Undo a pushed commit safely | git revert <sha> |
| Recover something you think you lost | git reflog |
reset --hard and pushed commits
git reset --hard discards work permanently. git revert creates a new commit that undoes an old one, preserving history — which is the only safe option once a commit is pushed and others may have pulled it.
git reflog records every position HEAD has held for 90 days. Almost anything you “lost” is recoverable from it. Look there before panicking.
Pull requests
The unit of review. A PR proposes merging a branch and gives a place to discuss it.
Workflow:
usethis::pr_init("feature-name")- Commit your work
usethis::pr_push()— pushes and opens the PR page- Reviewer comments; you push more commits to the same branch
- Merge, then
usethis::pr_finish()
A PR description that gets a fast review:
## What
Adds `derive_trtemfl()` for the treatment-emergent adverse event flag.
## Why
Required by SAP section 9.2. Previously computed inline in three
different table programs with slightly different logic.
## How
Events on or after TRTSDT and within 30 days of TRTEDT are flagged "Y".
Partial start dates impute to the earliest possible date (conservative).
Subjects with no TRTSDT are flagged "N".
## Testing
Seven unit tests including the 30-day boundary and missing TRTEDT.
Output compared against the SAS QC dataset for study ABC-101: identical
for all 1,847 records.
## Review focus
Please check the boundary convention in `derive_trtemfl()` line 34 —
"within 30 days" is inclusive here. Confirm this matches the SAP.The “review focus” section is what separates a two-day review from a two-hour one.
Reviewing code
Look for, in order:
- Correctness — does it implement the specification?
- Edge cases — empty input, all
NA, single subject, boundary values - Tests — do they cover the logic, including the edges?
- Readability — will someone else understand this in a year?
- Consistency — does it match how the rest of the codebase works?
Style is last, and is best handled by a linter rather than a human:
lintr::lint_package()
styler::style_pkg()
usethis::use_github_action("lint")Review comments should be specific and kind. “This could produce a wrong result when TRTEDT is missing — see line 34” is useful. “This is wrong” is not.
GitHub Actions
Continuous integration: run checks automatically on every push.
usethis::use_github_action("check-standard") # R CMD check on 3 platforms
usethis::use_github_action("test-coverage")
usethis::use_github_action("lint")
usethis::use_github_action("pkgdown")A minimal workflow:
# .github/workflows/R-CMD-check.yaml
name: R-CMD-check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
R-CMD-check:
runs-on: ubuntu-latest
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true
- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::rcmdcheck
needs: check
- uses: r-lib/actions/check-r-package@v2Every push now runs R CMD check. A PR that breaks the tests shows a red cross before a human looks at it. In a validated environment this is a documented, repeatable verification step that runs on every change — which is a much stronger statement than “the programmer ran the tests”.
For an renv-locked project, make CI restore the lockfile instead of resolving fresh package versions from CRAN:
- uses: r-lib/actions/setup-r@v2
with:
r-version: renv
- uses: r-lib/actions/setup-renv@v2That makes the CI R version and package library follow the same lockfile your team uses locally.
Version control for regulated work
Git supplies most of what an audit trail requires, but not all of it.
| Requirement | Git gives you | Also needed |
|---|---|---|
| Who changed what, when | git log, signed commits |
Identity mapped to a trained user |
| Why it changed | Commit messages, PR discussion | Link to a change request |
| Independent review | PR approval records | Documented reviewer qualification |
| Reproducible builds | Tagged commits | renv.lock + container image |
| Protection against tampering | Content-addressed history | Protected branches, signed commits |
Practical controls:
# Require review before merging to main (GitHub branch protection)
# Settings -> Branches -> Add rule -> Require pull request reviews
# Sign commits so authorship is cryptographically verifiable
git config --global commit.gpgsign true
git config --global user.signingkey <key-id>And the habit that matters most: tag every delivery, and record the tag, the renv.lock hash and the container image in the deliverable’s documentation.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Committing patient data | Reportable incident | .gitignore first, git_vaccinate() |
| Committing credentials | Leak; rotate immediately | .Renviron, never committed |
| Huge commits (“end of week”) | Unreviewable, unrevertable | Small, logical commits |
| Message says “fix” | History is useless | Say what and why |
| Long-lived branches | Painful conflicts | Merge often, keep branches short |
reset --hard on pushed work |
Rewrites shared history | git revert |
Working directly on main |
No review | Branch and PR, always |
| Ignoring red CI | Broken main |
Fix or revert immediately |
Exercise 12.1 — Full feature workflow
Starting from a clean main, add a derive_agegr() function to your package via a branch and pull request. List every command in order, including the tests and the check.
Show solution
# 1. Start from an up-to-date main
usethis::pr_init("agegr")
# equivalent to: git switch main && git pull && git switch -c agegr
# 2. Create the function file
usethis::use_r("derive-agegr")
# ... write the roxygen-documented function ...
# 3. Create and write the tests
usethis::use_test("derive-agegr")
# ... write tests ...
# 4. Verify locally BEFORE pushing
devtools::document()
devtools::test()
devtools::check()
# 5. Commit
gert::git_add(c("R/derive-agegr.R", "tests/testthat/test-derive-agegr.R",
"man/derive_agegr.Rd", "NAMESPACE"))
gert::git_commit("Add derive_agegr() for pooled age group categorisation
Implements the AGEGR1 categories from SAP section 7.1:
<65, 65-74, 75-84, >=85. Returns a factor with all levels
present so that zero-count groups appear in summary tables.
Refs: ABC-158")
# 6. Push and open the PR
usethis::pr_push()
# 7. Address review comments with further commits, then push again
gert::git_commit("Return factor with explicit levels per review comment")
usethis::pr_push()
# 8. After the PR is merged on GitHub
usethis::pr_finish()
# switches back to main, pulls, deletes the local branchcheck() before pushing. A CI failure that you could have caught locally wastes a reviewer’s attention and slows everyone down.
Exercise 12.2 — Recover from mistakes
Give the commands for each:
- You committed to
mainbut meant to be on a branch. Not yet pushed. - You committed a file containing a database password. Not yet pushed.
- You pushed a commit that broke the pipeline; others have pulled it.
- You ran
git reset --hardand lost two hours of committed work.
Show solution
(a) Move the commit to a branch.
git branch feature/my-work # create a branch pointing at the current commit
git reset --hard HEAD~1 # move main back one commit
git switch feature/my-work # the work is safely hereSafe because the commit exists on the new branch before main is moved.
(b) Remove the secret before it is pushed.
git rm --cached config/secrets.R # unstage but keep the file locally
echo "config/secrets.R" >> .gitignore
git commit --amend # rewrite the commit without the fileAmending is only acceptable because nothing was pushed. Rotate the password anyway — it existed in a file on disk and possibly in your shell history. If it had been pushed, treat it as disclosed and follow the incident process; history rewriting does not un-disclose it.
(c) Revert a pushed commit.
git revert a3f9c21 # creates a new commit that undoes a3f9c21
git pushrevert rather than reset because others have the commit. Rewriting shared history forces everyone else into a painful recovery, and in a regulated context destroys the audit trail.
(d) Recover from the reflog.
git reflog
#> a3f9c21 HEAD@{0}: reset: moving to HEAD~3
#> 8b2d4e1 HEAD@{1}: commit: Add validation checks <- this is what you want
#> 7c1a3f9 HEAD@{2}: commit: Add derive_agegr
git switch -c recovered 8b2d4e1reset only moved the branch pointer. The reflog keeps every position HEAD has held for 90 days by default. This works for anything that was committed; uncommitted changes discarded by reset --hard are genuinely gone, which is the argument for committing often.
Recap
- Working directory → staging → repository;
git statustells you where you are - Commit messages explain why; the diff already shows what
.gitignorepatient data and secrets before the first commit- Branch for every change; keep branches short-lived to avoid conflicts
git revertfor pushed commits,git resetonly for local ones;git reflogto recover- Pull requests are where review happens — write a description that focuses it
- GitHub Actions turn “the programmer ran the checks” into “the checks ran”
- Tag every deliverable and record the tag alongside
renv.lock
Course complete. You now have the full toolchain: reproducible setup, a solid model of R’s data structures, reliable import, fluent data manipulation, tested functions, and a way to package and version the result.
Where next:
- Clinical Programming with R — apply all of it to SDTM, ADaM and TLFs
- R Shiny — build interactive tools on top of these functions
- Python — the same concepts in a second language