title: Git over HTTPS description: Clone, fetch and push Diffinery repositories with Git over smart HTTP, authenticate with tokens, and what Diffinery checks and triggers on every push.
Diffinery serves Git over smart HTTP at https://diffinery.com/<owner>/<repo>.git. Any recent Git client works: clone, fetch, push, shallow clones, tags, force pushes and branch deletion behave as on GitHub. This page covers URLs, authentication, protocol support, pull request refs, push options, what happens on every push, and the limits and differences from GitHub.
There is no SSH access. Use HTTPS with a token.
Clone URLs
| What | URL |
|---|---|
| Clone, fetch and push | https://diffinery.com/acme/website.git |
The same without .git | https://diffinery.com/acme/website |
| Web page | https://diffinery.com/acme/website |
| Download a ref without Git | https://diffinery.com/acme/website/archive/main.zip (or .tar.gz) |
Both forms of the Git URL work. Archives are covered in Source code archives. The repository page shows the clone URL in its Clone box with a Copy button, and the REST API returns it as clone_url from GET /api/v3/repos/acme/website.
git clone https://diffinery.com/acme/website.git
cd website
git push origin main
You need Git 2.26 or newer (see Protocol versions).
Authentication
Git authenticates with a personal access token (dfy_...) used as the HTTP password. Passkeys are only for the browser.
| Operation | Public repository | Private repository |
|---|---|---|
| Clone and fetch | No credentials needed | A token with at least the read scope, for an account with read access |
| Push | A token with the write (or admin) scope, for an account with write access | Same |
Anonymous callers can only read: they can clone and fetch public repositories, but every push needs a token. A read-scoped token works for clone and fetch (and for downloading Git LFS files) on any repository its account can read, and is refused for every push.
Create a token under Settings → Access tokens (https://diffinery.com/settings/tokens) or with diffinery token create. See Personal access tokens.
When Git asks for credentials:
- Username: anything. Diffinery ignores it. Your username,
x-tokenorgitall work. - Password: the token.
Diffinery also accepts the token as the username with an empty password, and as an Authorization: Bearer dfy_... header.
Let the CLI handle credentials
The diffinery CLI can act as Git's credential helper for the Diffinery host, so plain git clone, fetch and push use the token you logged in with. Install the CLI (needs Node.js 22 or later):
curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
Then:
diffinery login # paste a dfy_ token; saved to ~/.config/diffinery/config.json (mode 0600)
diffinery auth setup-git # configure git to ask diffinery for credentials
git clone https://diffinery.com/acme/website.git
diffinery auth setup-git runs the equivalent of:
git config --global --unset-all credential.https://diffinery.com.helper
git config --global --add credential.https://diffinery.com.helper ""
git config --global --add credential.https://diffinery.com.helper "!diffinery auth git-credential"
The empty entry resets the helper list for this host, so a generic helper such as your OS keychain doesn't answer first. The helper answers only for the configured Diffinery host and protocol, with username x-token and the token from the config file or the DIFFINERY_TOKEN environment variable. It never stores anything (store and erase are no-ops), so revoking or replacing the token takes effect at once. The diffinery command must be on your PATH for Git to run it.
Note: Git only asks a credential helper (or you) for a token when the server answers
401. Private repositories do that. When you push to a public repository without credentials, the server answers403(write access required) instead, so Git never asks and the push fails withThe requested URL returned error: 403. Pass the token with the push itself, for examplegit -c http.extraHeader="Authorization: Bearer $DIFFINERY_TOKEN" push, as shown in Other ways to pass the token.
diffinery repo clone acme/website clones with the token passed as a one-off HTTP header that is never written to .git/config.
Other ways to pass the token
| Method | Example | Notes |
|---|---|---|
| Git's own helper | git config --global credential.helper store | Git prompts once, then stores the token in plain text in ~/.git-credentials. An OS keychain helper (osxkeychain, manager, libsecret) is safer. |
| Header for one command | git -c http.extraHeader="Authorization: Bearer $DIFFINERY_TOKEN" clone https://diffinery.com/acme/website.git | Good for CI. Nothing is saved. |
| Token in the URL | https://x-token:dfy_...@diffinery.com/acme/website.git | Works, but the token ends up in .git/config and shell history. Avoid outside throwaway jobs. |
Inside Diffinery Actions, actions/checkout authenticates with the job's own token automatically. That token can read the repository but not push to it (see Job tokens).
Token scopes and roles
What a push or fetch may do is the lower of your role on the repository and your token's scope:
| Token scope | Acts as at most |
|---|---|
read | read: clone and fetch only. Every push is refused. |
write | maintain: push branches, move and delete tags |
admin | admin: also bypass branch protection rules that let admins bypass, and bypass push protection |
A token restricted to some repositories gets 404 repository not found for every other repository. Agents' tokens follow the same rules plus the agent guardrails (see Agents and protected branches).
Job tokens
Each Actions job gets a token (GITHUB_TOKEN, starting with djt_). It works for Git and the REST API on the job's own repository only, with read access, and stops working when the job completes. A workflow can clone and fetch its repository but can't push to it with this token. To push from a workflow, store a personal or agent token as a secret and use that. This differs from GitHub, where GITHUB_TOKEN can push when the workflow has contents: write.
Protocol versions
| Operation | Protocol | Notes |
|---|---|---|
Clone and fetch (git-upload-pack) | Git protocol v2 only | Git 2.26 and newer use v2 by default. |
Push (git-receive-pack) | Smart HTTP v0 | Works with every Git version that supports smart HTTP. |
| Dumb HTTP | Not supported |
If a clone or fetch arrives without protocol v2, the server answers HTTP 400 with this message (Git itself only prints The requested URL returned error: 400):
Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)
Older Git versions that support v2 (2.18 to 2.25) work once you run git config --global protocol.version 2. Dumb HTTP requests get HTTP 403 with dumb HTTP protocol is not supported; update git.
Supported protocol v2 features: ls-refs (including unborn, so cloning an empty repository sets up the default branch) and fetch with shallow and wait-for-done (so push.negotiate=true works). Only SHA-1 repositories are supported (object-format=sha1). Request bodies compressed with gzip are accepted.
Push supports report-status, report-status-v2, delete-refs, side-band-64k, quiet, atomic, ofs-delta and push-options. Signed pushes (git push --signed) are not supported.
Shallow clones
Shallow clones and fetches work:
git clone --depth 1 https://diffinery.com/acme/website.git
git clone --shallow-since=2026-01-01 https://diffinery.com/acme/website.git
git fetch --depth 50
git fetch --unshallow # get the full history later
Differences and limits:
| Option | Behaviour |
|---|---|
--depth <n> | Supported. |
--shallow-since <date> | Supported. |
--unshallow | Supported. |
--deepen <n> | Treated like --depth <n> counted from the commits you fetch, not from your current shallow boundary. Use a larger --depth or --unshallow to get more history. |
--shallow-exclude <ref> | Not supported. The fetch fails with --shallow-exclude is not supported; use --depth or --shallow-since. |
--filter=... (partial clone, blobless or treeless clones) | Not supported. Diffinery doesn't advertise the filter capability, so Git warns that filtering is not recognized by the server and makes a full clone. A client that sends a filter anyway gets partial clones (--filter) are not supported yet. |
--sparse / sparse checkout | Works on the client side, but every object is still downloaded. |
actions/checkout in Diffinery workflows uses fetch-depth: 1 by default, like GitHub.
Pull request refs
Like GitHub, Diffinery keeps two refs for each open pull request:
| Ref | Points at |
|---|---|
refs/pull/<n>/head | The pull request's head commit. |
refs/pull/<n>/merge | A test merge commit of the head into the current base branch. It is recomputed on pull request events (opened, reopened, new commits on the head branch) and removed while the pull request conflicts with its base. |
Fetch them by name:
git fetch origin pull/12/head:pr-12
git checkout pr-12
# The test merge that pull_request workflows run on
git fetch origin refs/pull/12/merge
git log -1 FETCH_HEAD
To fetch every pull request head, add a refspec:
git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*'
git fetch origin
You can't push to refs/pull/: Git reports refs/pull/ is managed by Diffinery for pull requests. These refs are hidden from push, so git push --mirror leaves them alone. pull_request workflows check out refs/pull/<n>/merge. See Pull requests.
Push options
Diffinery reads one push option:
| Option | Effect |
|---|---|
allow-secrets=<reason> | Push even though push protection found possible secrets. Only works for a person (not an agent) with the admin role on the repository, using a token with the admin scope. The reason (up to 200 characters) is shown in the push output, recorded in the audit log as push.secret_bypassed, and emailed to the pusher and the repository's admins. An empty reason doesn't count. |
git push -o allow-secrets="test fixture, key is revoked" origin main
Other push options are accepted and ignored. See Push protection.
What happens when you push
Every push goes through these steps, in this order. If an earlier step fails, nothing after it happens.
Authorization. You must send a token: pushes without credentials are refused. Your account needs write access, your token the
writeoradminscope, and the repository must not be archived. Otherwise the server answers HTTP 401 (authentication required, when a private repository gets no credentials, which makes Git ask for them) or HTTP 403 (write access requiredwhen your role or aread-scoped token only allows reading, orrepository is archived (read-only)). Git shows these asAuthentication failedorThe requested URL returned error: 403.Size check. A push larger than 100 MB is refused. Git shows
remote unpack failed: push exceeds 100 MB; split it into smaller pushes, or an HTTP error if the request is stopped before it reaches Diffinery.Unpacking. The pack is parsed and every object checked.
Push protection. New text files under 1 MB are scanned for credentials such as AWS keys, GitHub and Diffinery tokens, Slack and Stripe keys, Google, Anthropic and OpenAI API keys, and private keys. If any are found, the whole push is rejected, nothing from it is stored, and the output lists each finding:
remote: Push blocked: possible secrets detected. remote: AWS access key in blob 3f2a9c81d0 remote: Remove the secret from your commits, or rotate it and contact an admin.A file that contains the text
diffinery:allow-secretis not scanned (use it for test fixtures). Blocked pushes are audited aspush.blocked_secret, and the pusher gets an email (for an agent, the person who owns it). See Push protection.Ref checks. Each updated ref is checked: valid ref name, write role, not under
refs/pull/or the merge queue'sgh-readonly-queue/branches, objects present, tag rules, branch protection and agent guardrails (see the next sections). Withgit push --atomic, if any ref fails, none is updated (atomic transaction failed).Ref update. Each ref is moved with a compare-and-swap. If someone else pushed in between, Git shows
fetch first (stale info).After the update, for every ref that moved:
A
pushevent goes to webhooks, notifications and the audit log (git.push,git.force_pushorgit.delete_ref).Open pull requests whose head is the pushed branch are updated (
synchronize), stale approvals are dismissed when the base branch's rule asks for it, and the push output links them:Updated pull request #12: https://diffinery.com/acme/website/pull/12.For a new branch with no pull request, the output suggests one:
remote: Create a pull request for 'feature/login': remote: https://diffinery.com/acme/website/compare/feature%2FloginWorkflows in
.github/workflows/(or.diffinery/workflows/) of the pushed commit whoseon: pushfilters match start a run. A pull request update also starts itspull_requestworkflows. A workflow file that fails to parse creates a failed run that shows the errors. If starting workflows fails, the push still succeeds and the output showsWorkflow trigger error: .... See Events that trigger workflows.A push to the default branch updates the repository's
on: scheduleworkflows.A merge queue whose base branch moved is rechecked. See Merge queue.
Deleting a branch or tag sends the push event but starts no workflows.
Branches, force pushes and deletion
On a branch without a protection rule, anyone with write access can create, update, force push and delete it:
git push origin feature/login # create or update
git push --force-with-lease origin feature/login
git push origin --delete feature/login # delete
The default branch can never be deleted (cannot delete the default branch). Change the default branch first under Settings → General.
On a branch that matches a branch protection rule:
| Rule | Push refused with |
|---|---|
| Require a pull request before merging | protected branch: changes must be made through a pull request |
| Force push without Allow force pushes | protected branch: force push not allowed |
| Deletion without Allow deletion | protected branch: deletion not allowed |
| Require linear history and the push adds a merge commit | protected branch: merge commits not allowed (linear history) |
Admins (with an admin-scoped token) bypass these rules on push unless the rule has Do not allow administrators to bypass these rules.
When several rules match a branch, only one applies: the rule whose pattern is exactly the branch name (for example main) wins over wildcard patterns (such as * or release/*); otherwise the oldest matching rule applies. This is the same order GitHub uses. You can also create, delete and compare branches on the repository's Branches page (see Browsing code).
Tags
git tag -a v1.2.0 -m "Release 1.2.0"
git push origin v1.2.0 # or: git push --tags
git push origin --delete v1.2.0
| Action | Needs |
|---|---|
| Create a new tag | write |
| Move an existing tag (force push it) | maintain |
| Delete a tag | maintain |
Otherwise Git shows only maintainers may move tags. Branch protection rules don't apply to tags. Lightweight and annotated tags both work, and the Tags page lists them.
Tags and the other Diffinery features:
- Releases. A release points at a tag. Publishing a release (not saving a draft) for a tag that doesn't exist yet creates the tag at the release's target (the default branch unless you choose another), with the same checks as pushing it, and the tag push starts
on: pushworkflows like any other. Deleting a release keeps its tag. See Releases. - Archives. Every tag can be downloaded as
https://diffinery.com/acme/website/archive/refs/tags/v1.2.0.zip(or.tar.gz). The REST API returns these aszipball_urlandtarball_urlinGET /api/v3/repos/acme/website/tags. - Environments. A deployment environment can allow tags by pattern. Tags pushed by an agent never match those patterns, so an agent can't start a deployment by pushing a tag. See Environments.
- Workflows. Pushing a tag starts
on: pushworkflows whosetagsfilters match. Publishing a release startson: releaseworkflows. See Events that trigger workflows.
Agents and protected branches
Agent accounts push like people, with these guardrails:
- They can't push to the default branch or to any branch matching a protection rule, unless that rule has Allow agents to push directly. Git shows
agents may not push to protected or default branches; open a pull request. - They can't bypass push protection, whatever their role.
- Commits they create through the REST API or MCP (without a Git client) are stamped with a
diffinery-agentcommit header naming the agent and the person it acts for.
So an agent's normal flow is: push a branch, then open a pull request. See Agents.
Limits and differences from GitHub
| Topic | Diffinery |
|---|---|
| Transport | HTTPS only. No SSH and no git://. |
| Fetch protocol | Protocol v2 only (Git 2.26+, or protocol.version=2). |
| Push size | 100 MB per push. Split bigger histories (see Troubleshooting). |
| Large files | Use Git LFS. Files over 1 MB in a push are not scanned by push protection. |
Partial clone, --shallow-exclude | Not supported yet. |
--deepen | Behaves like --depth. |
| Packs sent to clients | Sent without deltas, so clones and fetches download more bytes than from GitHub. |
| Signed pushes | Not supported. Signed commits push normally. |
GITHUB_TOKEN in workflows | Read-only; can't push. |
| Repository rename or transfer | The old URL keeps working, as on GitHub: clones, fetches and pushes are served under the old name, and a push prints This repository moved. Please use the new location: with the new URL. Update remotes anyway. |
refs/pull/* | Same as GitHub: fetchable, not pushable, hidden from push --mirror. |
| Ref names | Must start with refs/ and follow Git's ref name rules, otherwise invalid ref name. |
Troubleshooting
Git shows the server's message for errors that happen inside the Git protocol (remote error: ..., remote rejected, remote: ... lines). For HTTP-level errors (401, 403, 404, 400) it only shows the status code; the table gives the server's reason for each.
| What you see | Cause | Fix |
|---|---|---|
The requested URL returned error: 400 on clone or fetch | Git is older than 2.26 or protocol v2 is turned off. The server's message is Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2). | Update Git, or run git config --global protocol.version 2. |
Authentication failed after entering a password | You entered a password instead of a token, the token expired or was revoked (invalid, expired or revoked token), or it isn't a dfy_ token (unrecognised token; create one under Settings → Access tokens). | Create a token under Settings → Access tokens and use it as the password. Clear the old one from your credential helper (for example git credential reject, or your keychain). |
repository '...' not found for a repository you can see in the browser | The token belongs to another account, is restricted to other repositories, or Git sent stale credentials. | Check with diffinery whoami and diffinery token list. |
The requested URL returned error: 403 on push | Your role or your token's scope only allows reading (write access required), or the repository is archived (repository is archived (read-only)). | Ask an admin for write access, use a write token, or unarchive the repository. |
The requested URL returned error: 403 when pushing to a public repository, and Git never asked for a token | Git sent no credentials. For a public repository the server answers an anonymous push with 403 (write access required), not 401, so neither Git nor your credential helper offers a token. | Send the token with the push: git -c http.extraHeader="Authorization: Bearer $DIFFINERY_TOKEN" push origin main. |
remote unpack failed: push exceeds 100 MB; split it into smaller pushes | The pack for this push is larger than 100 MB. | Push history in steps, oldest first, for example git push origin <older-commit>:refs/heads/main, then later commits. Move large binaries to Git LFS. |
[remote rejected] ... (secret detected (push protection)) | A pushed file contains something that looks like a credential. | Remove it from every commit in the push (git commit --amend, or git rebase -i), rotate the credential, and push again. See Push protection. |
[remote rejected] ... (fetch first (stale info)) | The branch moved since you fetched. | git pull --rebase (or fetch and merge), then push. |
[remote rejected] ... (missing objects) | The push referenced objects the server doesn't have, often after a shallow clone. | Fetch the full history (git fetch --unshallow) and push again. |
[remote rejected] ... (agents may not push to protected or default branches; open a pull request) | An agent pushed to a protected or default branch. | Push to another branch and open a pull request. |
remote error: --shallow-exclude is not supported; use --depth or --shallow-since | --shallow-exclude isn't supported. | Use --depth or --shallow-since. |
the receiving end does not support --signed push | Signed pushes aren't supported. | Push without --signed. |
warning: filtering not recognized by server, ignoring | Partial clones aren't supported. | Nothing to do; Git made a full clone. |