Tutorials › Statistical Methods for Single-Cell RNA Sequencing › Git Versioning for Analysis Code and Reports

Reproducible End-to-End Workflows · Tutorial 690 of 700

Git Versioning for Analysis Code and Reports

Use Git to organize analysis changes, compare revisions, and link reported results to a specific code commit.

Intermediate 12 min read

What You'll Learn

  • Distinguish a Git working tree, staging area, commit, and branch.
  • Use commits to save reviewable snapshots of analysis code and reports.
  • Compare revisions with Git diff and interpret its direction.
  • Use branches for isolated changes and merge completed work.
  • Create tags and record commit identifiers for reported results.
  • Recognize what Git history does not establish about an analysis run.

Why Version Analysis Code and Reports?

A single-cell analysis changes over time: a filtering rule is revised, a plotting function is corrected, or a report is updated to reflect a new result. Without a reliable record of those changes, it can be difficult to tell which version of the code produced a figure or whether a later edit altered the analysis. Git provides a history of saved code and report states, along with tools for comparing them.

The earlier material on computational and statistical reproducibility distinguishes being able to inspect an analysis from being able to reproduce its results. Git supports the first goal and helps with the second by identifying code revisions. A commit identifier alone, however, does not prove that a particular run used that code or that all other conditions of the run were the same.

Definition: A Git repository stores project files and their version history. The working tree is the current set of files on disk. The staging area records the proposed file changes for the next commit. A commit is a saved snapshot of the staged project state, accompanied by metadata and a reference to its parent commit or commits. A branch is a movable name for a line of development; a tag is a name used to mark a particular commit.

From Edits to Commits

Git does not automatically save every edit. A typical workflow has three distinct states: the committed history, any unstaged edits in the working tree, and staged edits ready to be included in the next commit. The commands git status, git add, and git commit help inspect and move changes through these states.

For example, git status --short summarizes differences between the working tree, staging area, and current commit. git add scripts/qc.R reports/qc.qmd stages the current versions of those two files. Then git commit -m "Revise QC filtering and report" saves the staged changes. A file edited after it was staged may have further unstaged changes, so inspect the status again before committing. A commit contains what was staged, not automatically every modification on disk.

A commit is a snapshot, not merely a saved text patch. Git can show differences between snapshots, but each commit records the project state at that point in the history. In ordinary use, commits are linked through parent commits, forming a history that can be inspected and compared.

Worked Example: Committing a QC Report Update

Suppose a hypothetical project contains scripts/qc.R, which applies quality-control filters, and reports/qc.qmd, which describes those filters and summarizes their results. An analyst changes the code and updates the report. The repository’s current commit is named c82f10a (an illustrative abbreviated identifier).

First, the analyst checks the changes with git status --short and git diff. The status shows that both files have been modified, and the diff confirms that the code and report describe the intended update. The analyst then stages just those files with git add scripts/qc.R reports/qc.qmd and checks the status again.

The analyst saves them with git commit -m "Update QC filter and report". Git creates a new commit, for example e13bd72, whose parent is c82f10a. The new commit contains the staged versions of the two files. If another modified file was not staged, it is not included in this commit.

This is useful because a reviewer can inspect what changed and when the project’s saved state changed. The commit does not by itself establish that the report was successfully rendered or that the analysis ran using this code; those claims require an appropriate record of the run.

Branches, Merges, and Tags

A branch lets an analyst make a related set of changes without moving the project’s main line of development immediately. In a typical workflow, git switch -c revise-qc creates and switches to a new branch called revise-qc. Commits made there advance that branch’s name, while the main branch continues to point to its own latest commit. Branches are names for commits, not separate copies of all project files.

When work on a branch is ready to join the main line, it can be merged. If the histories allow it, Git may make a fast-forward merge, moving the main branch name forward. If both lines have new commits, Git may create a merge commit with both lines as parents. If changes conflict, Git stops and asks the user to resolve the conflicting file content before completing the merge. A merge records a combination of histories; it does not certify that the combined code is scientifically appropriate.

A tag marks a commit with a meaningful name, such as a release identifier or a milestone for a report. An annotated tag also stores a message and tag metadata. For example, git tag -a qc-report-v1.1 -m "QC report revision 1.1" creates an annotated tag at the current commit. A tag is convenient to read and cite, but it is technically possible to move or delete it. For durable records, protect tags according to the project’s policy and record the commit identifier they point to.

Worked Example: Comparing Two QC Revisions

Consider a hypothetical project with two tagged revisions. At qc-report-v1.0, the QC code retains cells with at least 200 detected genes and the report gives a retained-cell count of 1,240. At qc-report-v1.1, the minimum is raised to 250 and the report gives a count of 1,183. Assume the same input data and the same definition of detected genes were used for the two reported counts.

To see the change summary, the analyst runs git diff --stat qc-report-v1.0 qc-report-v1.1. To inspect the relevant files, the analyst runs git diff qc-report-v1.0 qc-report-v1.1 -- scripts/qc.R reports/qc.qmd. This comparison shows changes needed to go from the first named revision to the second. It can reveal the changed threshold and the revised report text.

The reported count falls by 1,240 − 1,183 = 57 cells. Since the threshold increased and the inputs and gene-count definition are assumed unchanged, a decrease is consistent with the filtering rule: cells passing a minimum of 250 detected genes are a subset of those passing a minimum of 200. This check does not establish that every detail of the two runs was identical; the assumption about inputs and definitions must be supported separately.

The comparison is informative for reviewing code and report history. If the code diff changes the threshold but the report still describes 200 genes, the mismatch is a warning to investigate. Conversely, a text diff can show that the report changed without revealing whether its figures were regenerated from the revised code.

Linking a Result to a Code Version

For a result to be linked to a particular revision, record the commit identifier associated with the run, rather than only a branch name such as main. A branch name can move as new commits are made; a commit identifier points to a particular saved commit. The identifier is useful only if the repository and its history remain accessible.

A practical procedure is to check the current commit with git rev-parse HEAD and inspect git status --short before running the analysis. If the working tree has uncommitted changes, the commit identifier alone does not identify those changes. Commit the intended code first, or record the uncommitted state clearly and do not describe the commit as an exact record of the code that ran. After the analysis, store the commit identifier alongside the report or run record.

Worked Example: Recording the Code Version for a Result

A hypothetical analyst has committed a single-cell QC analysis and is about to generate the report for a project review. The command git rev-parse HEAD returns the illustrative full commit identifier a4c91e7b62d0..., and git status --short returns no entries. The empty status output means Git reports no staged or unstaged tracked-file changes and no untracked files; it does not make claims about external inputs or the execution environment.

The analyst records the commit identifier in the run record with the report filename and the date of the run, for example: “QC report generated from commit a4c91e7b62d0...; working tree clean at start.” If the report is committed, its text can also state the code commit used to generate it. The report’s own later commit will have a different identifier; that later identifier describes the report and project snapshot, not automatically the code revision used for the earlier run.

If the analyst instead records only “branch main,” a later reader cannot tell which point in the moving branch history was used. Recording a specific commit resolves that ambiguity. It still does not prove that the code was executed, that the report corresponds to the run, or that the same inputs and computational conditions can be recovered.

Why Commit Identifiers Identify Snapshots

Git’s commit structure explains why recording a commit identifier is more precise than recording a branch name. A commit refers to a project tree and includes information such as its parent commit, author and committer metadata, and message. Git computes an object identifier from the commit object’s contents. Changing the committed tree or commit metadata produces a different commit object and therefore a different identifier, apart from the theoretical possibility of a hash collision.

The reasoning can be summarized as follows:

1
A commit refers to a tree.
The tree represents the files and their contents saved in that project snapshot.
2
The commit object records context.
It includes the tree reference, parent reference or references, metadata, and message.
3
The object identifier identifies that commit object.
Changing the saved tree or recorded commit contents changes the object being identified. A branch name, by contrast, is a reference that can be updated to point to another commit.

This is why an exact commit identifier is valuable for tracing code history. It identifies a saved state in the repository, not the full conditions under which a result was produced.

Diagnostics, Alternatives, and Limitations

A few routine checks make version history more useful. Before committing, inspect git status to see what is staged and what remains unstaged, then inspect git diff for unstaged changes. To inspect staged changes, use git diff --staged. To review project history, git log --oneline --graph --decorate --all displays commits, branches, and tags in a compact form. These checks can expose an accidentally omitted report, a change staged but not intended, or a tag that points somewhere unexpected.

Common failures include committing only the code while leaving the report’s explanation outdated, relying on a branch name that later moves, and assuming a clean working tree proves the report came from the code at HEAD. The correction is to review both code and report changes, record the exact commit identifier for the run, and distinguish evidence of a clean repository state from evidence that a run used that state.

Copying project folders with names such as “final” and “final-new” is a simple alternative, but it is easy to lose track of which files changed and why. Git provides a navigable history and direct revision comparisons. A shared backup can protect against data loss, but it does not by itself provide the same structured record of code changes. These approaches can complement one another, but they serve different purposes.

Git’s limits matter especially in single-cell work. A commit records project files, not proof that an analysis executed successfully or that a report was generated from those files. Nor does a commit alone preserve all information about the inputs or software used. Git is most effective when commits are understandable, reports are reviewed alongside code, and run records explicitly identify the code revision they refer to.

Key takeaway: Use commits to save reviewable project states, branches to isolate ongoing changes, and tags to name important revisions. For a reported result, record the specific commit identifier and the state of the working tree; do not treat a branch name or commit alone as proof of full reproducibility.

Check Your Understanding

Answer each question, then compare your reasoning with the solutions.

  1. What is the difference between a staged change and a committed change?
  2. A branch named main points to commit A today and commit B next week. Which should a run record cite if it used commit A?
  3. Two revisions use the same input data. One retains cells with at least 100 detected genes, and the other requires at least 150. Can the second revision retain more cells under that rule? Explain.
  4. Why does recording a commit identifier not, by itself, prove that a report was generated from that commit?
  5. What should be inspected to find changes that have been staged but not yet committed?

Solutions. (1) A staged change is selected for a prospective commit; it is not part of the saved history until a commit is made. (2) Record commit A’s identifier, not just main, because the branch name moves. (3) No, assuming the same input data and detected-gene definition: the cells meeting the 150-gene minimum are a subset of those meeting the 100-gene minimum. (4) The identifier identifies a repository snapshot but does not establish that it was executed or that the report came from that run. (5) Use git diff --staged to inspect staged changes.

Clinical Biostats