Git and GitHub

Lesson 12 — R Programming

Lesson 12 of 12 Intermediate ~90 min

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 false

From 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 token

usethis::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 merging

git 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
ImportantA committed secret is a leaked secret

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-flags
usethis::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 up

A 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 --tags

That 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-groups

Resolve by editing the file to what it should be — often neither version verbatim — removing the markers, then:

git add conflicted-file.R
git commit

To abort and think again:

git merge --abort

Prevention 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
Warningreset --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:

  1. usethis::pr_init("feature-name")
  2. Commit your work
  3. usethis::pr_push() — pushes and opens the PR page
  4. Reviewer comments; you push more commits to the same branch
  5. 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:

  1. Correctness — does it implement the specification?
  2. Edge cases — empty input, all NA, single subject, boundary values
  3. Tests — do they cover the logic, including the edges?
  4. Readability — will someone else understand this in a year?
  5. 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@v2

Every 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@v2

That 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 branch
The order matters in one place: run check() 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:

  1. You committed to main but meant to be on a branch. Not yet pushed.
  2. You committed a file containing a database password. Not yet pushed.
  3. You pushed a commit that broke the pipeline; others have pulled it.
  4. You ran git reset --hard and 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 here

Safe 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 file

Amending 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 push

revert 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 8b2d4e1
The commits were never deleted — reset 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 status tells you where you are
  • Commit messages explain why; the diff already shows what
  • .gitignore patient data and secrets before the first commit
  • Branch for every change; keep branches short-lived to avoid conflicts
  • git revert for pushed commits, git reset only for local ones; git reflog to 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:

Back to top