Using Git
Clone one branch or tag, w/o history, with submodules w/o their history (shallow)
git clone -b v1.2.3 --recurse-submodules --shallow-submodules --depth 1 [repo]
Git LFS (Large File Storage)
Install Git LFS, then initialize it once for your user account:
# macOS
brew install git-lfs
# Ubuntu/Debian
sudo apt install git-lfs
git lfs install
Enable Git LFS in a repository and select the file types to store with it:
cd path/to/repository
git lfs track "*.zip" # use a pattern or a specific file path
git add .gitattributes # commit the tracking rules
git add path/to/archive.zip
git commit -m "Track ZIP files with Git LFS"
git push
Run git lfs ls-files to list tracked files. Tracking a pattern affects newly added files only; moving files
already committed to regular Git into LFS requires git lfs migrate, which rewrites repository history.
Publish an immutable release and move its major-version tag
Use two tags for a versioned GitHub Action or other project:
v1.0.1is the immutable release tag. Never move or reuse it.v1is a moving compatibility tag. Users who specifyuses: owner/repository@v1receive the latest compatiblev1.x.xrelease.
The GitHub Release must remain attached to v1.0.1. Only the v1 tag moves; there is no separate moving
GitHub Release.
1) Publish the GitHub Release
On the repository’s GitHub page:
- Open Releases and select Draft a new release.
- Create (or choose) the
v1.0.1tag. - Add the release title, notes, and any assets.
2) Create or update the major-version tag
Set the immutable version and the moving major version:
version=v1.0.1
major=v1
Only after the GitHub Release for v1.0.1 is published, point v1 at it. On the first release this creates
v1; on every later release (v1.0.2, v1.1.2, …) the same command updates v1 to the newest immutable
tag. Fetch the tag GitHub just created, point the local v1 tag at the commit referenced by $version, then
force-update only that tag on GitHub:
git fetch origin tag "$version"
git tag -f "$major" "$version^{}"
git push --force origin "refs/tags/$major"
The ^{} suffix dereferences the version tag, so v1 points directly to the release commit. Force is
required because moving a tag is a non-fast-forward update (and is harmless the first time, when v1 does not
yet exist). Protect immutable tags such as v*.*.* with a GitHub tag ruleset, while allowing the major tags
intended to move.
Update a tag / release (rewrite an existing tag)
Use these Variables
tag: the GitHub tag name (e.g.v1.2.3)btag: temporary branch name created from the tagrtag: full tag ref path (refs/tags/<tag>), used for remote deletion
Example: tag=v1.2.3
btag="b$tag"
rtag="refs/tags/$tag"
1) Create and switch to a temporary branch from the existing tag
git fetch origin --tags
git switch -c "$btag" "$tag"
2) Make changes, commit, and push the temporary branch
git commit -am "Fixes for $tag"
git push -u origin "$btag"
3) Delete old local tag pointing to the old commit, recreate the tag locally at the new HEAD
(You’re already on $btag, so the extra git switch "$btag" is redundant.)
git tag -d "$tag"
git tag "$tag"
4) Replace the remote tag pointing to the new commit
First delete the old remote tag, then push the new one. Force-update the remote tag explicitly because tags are immutable by convention and many servers reject non-fast-forward tag updates unless forced.
git push origin ":$rtag"
git push --force origin "$rtag"
5) Update the GitHub Release (and assets) if necessary
- If you uploaded release assets (binaries / custom .tar.gz / etc.): update the existing release by removing the old assets and uploading the new ones (or delete and recreate the release). Moving the tag does not replace uploaded assets.
- If only GitHub-generated source archives are used (“Source code (tar.gz/zip)”): you can usually leave the release as-is; it will continue to be “for $tag”, and the generated archives will follow the updated tag (may take a short while due to caching).
- If release notes/changelog should change: either edit the existing release notes, or delete and recreate the release for $tag.
6) Cleanup: delete the temporary branch (remote + local)
git push origin --delete "$btag"
git switch main
git branch -D "$btag"
Branches
Create new branch and switch to it:
git checkout -b branchname
Switch to an existing branch:
git checkout branchname
Merge changes from branch1 into branch2 (one of them can be main). Use rebase for a clean history.
Alternatively, you can use merge to keep the history of both branches.
git checkout branch2
git merge/rebase branch1
Push a branch to remote:
git push -u origin branchname
Delete a remote branch:
git push origin --delete branchname
List all remote branches:
git branch -r: lists all remote branchesgit branch -a: lists all branches (local and remote)git branch -vv: lists all branches with their last commitgit branch --merged: lists branches that have been merged into the current branchgit branch --no-merged: lists branches that have not been merged into the current branch
Modify an existing pull request or merge request
A GitHub pull request (PR) or GitLab merge request (MR) from a fork tracks a source branch in that
fork and proposes merging it into a target branch in the original repository (often main). A fork
is a separate repository; it still contains branches. Add commits to the same branch in the same fork,
either in the browser or by pushing from a local clone, to update an open request automatically.
There is no need to create another request.
Update the code in the browser
You do not need to clone the fork. You still need permission to update its source branch: the author can edit their own fork, and maintainers can contribute when the author enables Allow edits from maintainers on GitHub or Allow contributions from members on GitLab.
- Open the existing PR or MR and identify its source fork and source branch.
- Open that fork in the browser and select the source branch, for example
my-feature. - Open the file and use the web editor. For changes across multiple files, use GitHub’s web editor or GitLab’s Web IDE.
- Review the changes and commit them directly to the existing source branch. If offered a choice, choose that branch rather than creating a new branch or request.
The existing PR or MR automatically includes those commits. Check the updated diff and check results on the request’s page.
You can also apply suggested changes from review comments directly on the PR or MR when you have permission to update the source branch. Applying a suggestion creates a commit on that branch. Reviewers without that permission can suggest changes for the author or an authorized maintainer to apply.
Update the code from a local clone
1) Identify the fork and its source branch
Find the source fork and branch on the request’s page, for example your-user/repository and my-feature.
You need write access to that fork’s source branch. Creating another fork or pushing to a different branch
will not update the existing request.
In your local clone, check which repository each remote points to:
git remote -v
The commands below use a remote named fork pointing to the request’s source fork. If that fork is already
your origin, replace fork with origin throughout. If no remote points to it, add one using the fork’s
SSH or HTTPS clone URL from GitHub or GitLab:
git remote add fork <fork-clone-url>
2) Switch to the source branch in the fork
Start with a clean working tree: commit or stash any unrelated changes before switching branches.
git status
git fetch fork
git switch my-feature
# If the branch does not exist locally, use this instead:
# git switch --track fork/my-feature
git pull --ff-only fork my-feature
If the pull reports diverging branches, reconcile the local and remote commits before continuing.
3) Edit the files, review the changes, commit, and push to the fork
Make the requested changes and run the relevant checks, then:
git diff
git add path/to/file path/to/another-file
git diff --cached
git commit -m "Address review feedback"
git push fork HEAD:my-feature
HEAD:my-feature pushes your current commit to the fork’s existing source branch. The original repository’s
PR or MR now includes the new commits. Review the updated diff and check results on its page.
4) Update the title or description if needed
Open the existing request on GitHub or GitLab and edit its title or description. These edits do not require a Git commit. If the scope changed, describe the final change so reviewers can understand it.
If you amended commits or rebased the source branch
Amending or rebasing commits already pushed rewrites their history. Coordinate with anyone sharing the
branch, then use git push --force-with-lease fork HEAD:my-feature to update the same request. If the lease
check rejects the push, fetch and review the remote changes before trying again. Branch protection may
forbid force pushes; adding a new commit avoids rewriting history.
Once a request has been merged, submit further changes through a new branch and a new PR or MR.
Take specific files from another branch (not a full merge)
When you only want one or a few files from another branch (or commit/tag), don’t merge the whole branch — pull just those paths into your current working tree.
⚠️ These commands overwrite the local copy of the listed paths. Any uncommitted changes to them are lost. Run
git statusfirst if unsure.
Preview the difference first
git diff main..otherbranch -- path/to/file # what would change in that file
git show otherbranch:path/to/file # print the file as it is on otherbranch
Copy the files over (replace local version)
# Modern (recommended). --source can be a branch, tag, or commit SHA.
git restore --source=otherbranch --staged --worktree -- path/to/file [more/files...]
# Classic equivalent (also stages the result):
git checkout otherbranch -- path/to/file [more/files...]
git checkout <branch> -- <path> updates both the working tree and the index (already staged).
git restore --source=<ref> -- <path> updates only the working tree by default — add --staged (as above)
to also stage it. Then commit normally:
git add path/to/file # only needed if the file is not already staged
git commit -m "Bring path/to/file from otherbranch"
Take only some changes within a file (interactive, hunk by hunk)
This is the closest thing to “merging” a single file: pick which hunks to bring in.
git restore -p --source=otherbranch -- path/to/file # or: git checkout -p otherbranch -- path/to/file
True 3-way merge of a single file
checkout/restore replace the file; they do not merge its content with yours. For an actual
line-by-line merge of one file between two versions, use git merge-file:
git show otherbranch:path/to/file > /tmp/theirs # their version
git show $(git merge-base HEAD otherbranch):path/to/file > /tmp/base # common ancestor
git merge-file path/to/file /tmp/base /tmp/theirs # merges into path/to/file, marks conflicts
Conflicts
1. Resolve Conflicts
- Check what’s conflicted:
git status
- Text conflicts (
UU, etc.): open file and fix<<<<<<< ======= >>>>>>>, then:
git add path
- Keep one side (choose ours or theirs):
git checkout --ours path # keep our version (rebase source)
git checkout --theirs path # keep their version (rebase target)
git add path
- You want the file removed (e.g.
DU/ delete-vs-modify conflict):
git rm -- path
# if the file is already gone locally:
git rm --cached -- path
git add -u
2. Finalize a rebase or a merge
git rebase --continue # repeat until it finishes
git push origin branch2
or
git commit -m "merge branch1 into branch2"
git push origin branch2
Unmerged status codes
DDboth deletedAUadded by usUDdeleted by themUAadded by themDUdeleted by us (often: decide keep vs delete; usegit rmto delete)AAboth addedUUboth modified
History
Show commit + diff for a specific file
git log -p -- path/to/file
Compact one-line history
git log --follow --date=short --pretty=format:"%h %ad %an %s" -- path/to/file
To remove all history from a repo:
git checkout --orphan new-main
git add -A
git commit -m 'new files'
git branch -D main
git branch -m main
git push -f origin main
git branch --set-upstream-to=origin/main main
Revert or undo the last commit
There are two very different ways to “undo” the last commit. Pick by whether the commit was already pushed.
Already pushed → git revert (safe, keeps history)
git revert does not delete the commit; it creates a new commit that reverses its changes. History is
preserved and no force push is needed, so this is the right choice for a shared/pushed branch.
git revert HEAD # opens an editor for the revert message
git revert --no-edit HEAD # reuse the default message, no editor
git push
To undo the revert itself, just revert the revert (git revert HEAD) — or drop it while still local with the
reset commands below.
Local only → git reset (rewrites history)
If the commit has not been pushed, move the branch pointer back one commit. What happens to the changes depends on the flag:
git reset --soft HEAD~1 # undo the commit, keep changes staged
git reset --mixed HEAD~1 # undo the commit, keep changes in the working tree (default)
git reset --hard HEAD~1 # undo the commit AND discard the changes
⚠️
--hardpermanently discards the working-tree changes from that commit. Rungit status(and, if unsure,git stash) first.
Common case: keep the work but redo the commit — git reset --soft HEAD~1, then re-stage/commit as needed.
Fix only the message or add forgotten files to the last commit without resetting:
git commit --amend # edit message and/or fold in staged changes
git commit --amend --no-edit # keep the message, just add the staged changes
If it was already pushed
Resetting a pushed commit rewrites history, so the push is non-fast-forward and must be forced. Prefer
--force-with-lease, which refuses if someone else pushed in the meantime:
git reset --hard HEAD~1
git push --force-with-lease
⚠️ Only do this on a branch you control. Rewriting shared history breaks other clones — on a shared branch use
git revertinstead.
Recover a commit dropped by mistake
reset does not immediately delete commits; the old tip stays in the reflog for a while:
git reflog # find the SHA of the lost commit
git reset --hard <sha> # or: git cherry-pick <sha>
Tokens (Personal Access Tokens)
A Personal Access Token (PAT) is a credential tied to your personal account, not to a repository. You always create it under:
Personal Settings > Developer Settings > Personal access tokens
Two key facts that are easy to get wrong:
- The token inherits the permissions of the account that creates it. To act on a repo (trigger its workflows, push, pull private images), that account must have the matching access to that repo.
- GitHub shows the token string only once, at creation. It cannot be read back later (not from your settings, not from a secret it was pasted into). If you lose it, regenerate.
You can regenerate an existing token (new value) or edit a classic token’s scopes in place (value
unchanged) — both under the same page. Always set a custom expiration date.
Which scope / permission do I need?
Pick the row for what the token must do, not for the repo it lives in:
| Goal | Classic scope | Fine-grained permission |
|---|---|---|
| Trigger another repo’s workflow (API) | repo / public_repo |
Actions → Read and write |
Add / edit .github/workflows/* files |
workflow |
Workflows → Read and write |
| Pull private container images | read:packages |
Packages → Read |
⚠️ Common mistake: the
workflowscope is only for committing workflow files. It does not let you trigger a run. To fire aworkflow_dispatch/repository_dispatchyou needrepo(public_repofor public repos) on a classic token, or Actions: Read and write on a fine-grained token.
Direct links
- Fine-grained: https://github.com/settings/personal-access-tokens/new
→ Owner
gemc, repo e.g.gemc/clas12-systems, permission Actions: Read and write - Classic: https://github.com/settings/tokens/new
→ Scope: ✅
repo(coverspublic_repo); addread:packagesfor private images
A token used by a local tool (not a workflow) should go in a file read by that tool, typically in the home
dir, for example .bob.
Secrets
A GitHub Actions secret is an encrypted variable stored in a repository’s settings. Workflows in that
repository read it at runtime via ${{ secrets.SECRET_NAME }}; the value is never printed in logs or
shown to anyone browsing the repo.
Keep the two pieces separate — they live in different places:
- The token (the
ghp_…/github_pat_…string) is created in your personal account (above). - The secret is a copy of that string, stored in the repository whose workflow will use it — i.e. the repo that does the triggering, not the repo being triggered.
How to create and store it
-
Generate the token in your personal account (Developer settings), choosing the scope/permission for the target repo you want the workflow to act on (see the table above). The account must have write access to that target repo.
-
Copy the token value shown on screen (visible only once).
-
Add the secret to the repository whose workflow uses it
- Go to that repo → Settings → Secrets and variables → Actions (e.g. https://github.com/gemc/pygemc/settings/secrets/actions)
- Click New repository secret
- Name: e.g.
GEMC_SRC_PAT(must match the name referenced in the workflow YAML) - Value: paste the token
- Click Add secret
Triggering a workflow in another repository
The built-in GITHUB_TOKEN cannot start workflows in a different repository, so cross-repo triggers
use a PAT stored as a secret. The pattern: repo A’s workflow calls the GitHub API to dispatch repo
B’s workflow.
You need three things:
- A PAT owned by an account with write access to repo B, scoped to trigger Actions
(classic
repo, or fine-grained Actions: Read and write on B). See the Tokens table above. - The secret holding that PAT, stored in repo A (the trigger source).
- A dispatchable trigger on repo B’s workflow — add
workflow_dispatch:(with optionalinputs:) to theon:block of the target workflow, otherwise the API call returns404/422.
The dispatch call (in repo A’s workflow)
- name: Trigger workflow_dispatch on repo B
run: |
curl --fail-with-body -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.B_PAT }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/<owner>/<repoB>/actions/workflows/test.yml/dispatches \
-d '{"ref":"main","inputs":{"triggered_by":"repo A"}}'
The inputs keys must match the inputs: declared under workflow_dispatch: in repo B’s workflow.
Security: guard workflow_run-chained triggers
A workflow with on: workflow_run runs privileged (it can see secrets/PATs) and fires for fork-PR
events too, so gate the job — otherwise a fork PR could reach the privileged step and any code it checks
out via workflow_run.head_sha. This is also why CodeQL flags “checkout of untrusted code in a privileged
context”.
Which field to check depends on the position in the chain — this trips people up:
-
1st hop — listening to a workflow triggered directly by
push/pull_request(e.g. aDeploythat runson: workflow_runofTest):github.event.workflow_run.eventis the original event, so guard withevent == 'push'.if: >- ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} -
2nd hop — listening to a workflow that is itself
workflow_run-triggered (e.g. a job that runs afterDeploy, which already runson: workflow_run): hereworkflow_run.eventis alwaysworkflow_run, neverpush. Usingevent == 'push'makes the job never run; usingevent == 'workflow_run'always passes (no protection). Guard on the source repo instead — thehead_*fields carry the original source through the chain:if: >- ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository }}head_repository.full_name == github.repositoryis true for same-repo pushes and false for fork-PR chains, which is exactly the untrusted-code-checkout protection you want.
Examples in this project
| Trigger source (repo A) | Secret name (in A) | Target (repo B) | Token needs (on B) |
|---|---|---|---|
gemc/pygemc |
GEMC_SRC_PAT |
gemc/src |
trigger Actions on src |
gemc/src |
CLAS12_SYSTEMS_PAT |
gemc/clas12-systems |
trigger Actions on clas12-systems |