Git over HTTPS
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). 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 your editor handle credentials
In VS Code, Cursor, Windsurf or VSCodium, the Diffinery extension adds Clone from Diffinery to the clone picker and answers Git's password prompts with the account you signed in with in the browser. No token to create or paste. In IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs, the Diffinery plugin does the same from Get from Version Control.
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:
curl -fsSL https://diffinery.com/install | bash
Then:
diffinery login # authorize in your browser; the token is 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.
Git only asks a credential helper (or you) for a token when the server answers 401. Diffinery answers 401 authentication required with a WWW-Authenticate: Basic realm="Diffinery" challenge to every anonymous push, to public repositories too, and to every anonymous clone or fetch of a private repository. So you can clone a public repository without credentials and push to it later: Git asks the helper for the token at the first push.
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. Whether that token can push depends on the workflow's permissions: (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_) that works for Git and the REST API and stops working when the job completes. As on GitHub, what it may do over Git follows the workflow's permissions::
contents permission | Git with the job token |
|---|---|
read | Clone and fetch the job's repository |
write | Also push to it |
none | Nothing on a private repository; a public one can still be cloned, as anyone can |
Without permissions: in the workflow, the job gets the repository's default: contents: read for repositories created now, write for repositories created before the setting existed. A repository admin changes the default with PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow; see Workflow permissions. To push from a workflow, ask for it:
permissions:
contents: write
Pushes with a job token have these limits:
- The token acts with at most the write role, so branch protection always applies to it.
- It can never create, change or delete files under
.github/workflows/or.diffinery/workflows/. Git rejects the ref with the reason: refusing to allow a GitHub App to create or update workflow<path>withoutworkflowspermission. Use a personal token for that. - If the run's code came from an agent (an agent triggered the run, pushed the branch last, or opened the pull request, or the run re-runs or follows through
workflow_runsuch a run), the token acts as an agent: it can't push to the default branch or a protected branch (agents may not push to protected or default branches; open a pull request). - A push made with a job token starts no new
pushworkflows, as on GitHub. - The token can also clone other public repositories (for example public submodules), but no other private repository.
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: a push without credentials gets HTTP 401 (
authentication required) with aWWW-Authenticatechallenge, for public and private repositories alike, which makes Git ask for credentials and retry. Your account needs write access, your token thewriteoradminscope, and the repository must not be archived. Otherwise the server answers HTTP 403 (write access requiredwhen your role only allows reading,this token needs the write scopefor aread-scoped token, orrepository is archived (read-only)), or HTTP 404 if the account can't see the repository at all. Git shows these asAuthentication failedorThe requested URL returned error: 403.Size check. A push larger than 100 MB (after decompression), or one whose pack unpacks to more than 512 MiB, 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. A push over 16 MB is staged in storage as it arrives and unpacked from there in pieces, one object at a time, so its memory use depends on its largest files rather than on its size. A smaller push whose objects don't all fit in memory at once, such as a long history, is unpacked in pieces too. Either way, unpacking may hold at most 80 MiB at once; a push that needs more is refused with
pack needs more than 80 MiB of memory to unpack; split it into smaller pushes.Push protection. New text files 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.
A force push or a deleted branch doesn't delete the commits it leaves behind: anyone who can read the repository can still fetch them by SHA. To remove a pushed secret for good, see Removing a secret that was pushed.
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) |
| Required status checks and the new commit hasn't passed one | protected branch: required check '<name>' has not run on <sha>; push to another branch and open a pull request (or has not finished, or ended with <conclusion>) |
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. |
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 | Follows permissions: as on GitHub (contents: write to push), but new repositories default to read-only, it never changes workflow files, and code from an agent is held to the agent rules. |
| 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 |
|---|---|---|
remote: Push refused: acme has reached its storage limit of N bytes ... and every ref [remote rejected] ... (storage limit reached) | The push brings new objects and the repository owner is at its storage quota (git objects, Git LFS objects, release assets and packages, 10 GB by default). Nothing from the push is kept. | Free space: delete releases, assets, image versions or repositories, or delete branches and then purge unreachable objects. Or ask a site administrator to raise the quota. Pushes that only delete or move refs still work. |
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, this token needs the write scope), or the repository is archived (repository is archived (read-only)). | Ask an admin for write access, use a write token, or unarchive the repository. |
| Git asks for a username and password when you push to a public repository you cloned without credentials | Expected: anonymous pushes get 401, so Git asks for credentials. | Enter any username and your token as the password, or set up a credential helper with diffinery auth setup-git. |
The requested URL returned error: 403 on push from a workflow | The job's token has no contents: write (Resource not accessible by integration). | Add permissions: contents: write to the workflow or job, or have an admin change the repository's default workflow permissions. |
[remote rejected] ... (refusing to allow a GitHub App to create or update workflow ...) | A workflow's job token tried to push a change to a workflow file. | Push workflow changes with a personal or agent token instead. |
remote unpack failed: the push needs more memory than one push may use; split it into smaller pushes (...) | Unpacking or storing this push ran out of the memory one push may use, even though it is under the size limits. Pushes over 16 MB are unpacked in pieces, so this now comes from a single very large file, or a delta against one. Nothing from the push is kept. | Push history in steps, oldest first, as for the 100 MB limit below. The GitHub importer does this by itself. |
remote unpack failed: pack needs more than 80 MiB of memory to unpack; split it into smaller pushes | Unpacking would hold more than 80 MiB at once: files under 4 MiB inflated, plus about 768 bytes per object. Very many objects (well over 100,000) or many small files that compress well hit it before the size limits. A push unpacked in pieces (over 16 MB, or one that didn't fit) counts its caches, the files in use and about 128 bytes per object instead, so there it comes from very large files. See Limits. | Push history in steps, oldest first, as for the 100 MB limit below. The GitHub importer does this by itself. |
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: Push refused: tree <id>: duplicate entry '<name>' (or another reason), remote unpack failed: fsck error: ... and [remote rejected] ... (fsck failed) | A tree or .gitmodules file in the push fails git's fsck checks: an entry that is empty, ., .., a spelling of .git, contains / or appears twice, a tree over 8 MiB, a commit or tag over 4 MiB, a .gitmodules that is a symbolic link, or a submodule whose name has .. or whose url or path starts with -. Nothing from the push is kept. | Run git fsck locally to find the object, fix the tree or .gitmodules in a new commit (or rewrite the commit that added it), and push again. |
[remote rejected] ... (protected branch: required check '<name>' has not run on <sha>; ...) | The branch requires status checks, and the commit you pushed hasn't passed them. | Push to another branch and open a pull request, or push a commit that already passed the checks. |
[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. |