Search by

oliverthiele / deployer-git-drift

Deployer recipe: detect and warn about direct server-side file changes before deployment

Maintainers

Package info

github.com/oliverthiele/deployer-git-drift

pkg:composer/oliverthiele/deployer-git-drift

Transparency log

Statistics

Installs: 70

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-09-01 13:58 UTC

This package is auto-updated.

Last update: 2026-09-01 13:58:52 UTC


README

Deployer recipe that detects and warns about files modified directly on the server (via FTP, SFTP, or SSH) before they are silently overwritten by the next deployment.

Packagist Version PHP License Changelog

Pre-1.0 — This package is under active development. Task names and configuration keys may still change before v1.0. Pin to an exact version and review the CHANGELOG before upgrading.

The Problem

Deployer uses an atomic symlink swap for zero-downtime deployments. This means there is no Git repository on the production server — only the deployed files exist. When someone modifies files directly on the server (editing a config file, quick-fixing a bug via FTP, uploading an asset), those changes are invisible. The next deployment silently overwrites them.

This recipe solves the problem by:

  1. Initializing a shallow Git repository in each release directory after deployment
  2. Checking for file changes in the current release before the next deployment starts
  3. Warning the developer and requiring explicit confirmation before overwriting

Requirements

  • PHP >= 8.4
  • Deployer >= 7.0
  • Git installed on the remote server

Installation

composer require --dev oliverthiele/deployer-git-drift

Usage

In your deploy.php:

require 'recipe/common.php';
require __DIR__ . '/vendor/oliverthiele/deployer-git-drift/src/GitDrift.php';

// Hook into the deployment flow
after('deploy:symlink', 'git-drift:init');
before('deploy:vendors', 'git-drift:check');

That single require is all that is needed. The recipe loads its own classes when no Composer autoloader is in play, so it works with a project-local Deployer as well as with a global installation or deployer.phar.

Hooks are opt-in by design — the recipe registers tasks only, not automatic hooks.

Configuration

// Always abort when drift is detected (default: false — ask interactively)
set('git_drift_abort_on_drift', true);

// Ignore paths that are expected to differ from the Git state
// Typical candidates: generated files, caches, installed dependencies —
// NOT shared_dirs/shared_files, those are handled automatically (see below)
set('git_drift_ignore_paths', [
    'vendor/',
    'node_modules/',
    'var/',
]);

// Additional tracked files that are expected to differ, to be rewritten on the
// server (e.g. rewrite rules regenerated by an install routine), or to be missing
// because something other than Git kept them off the server.
// Files or directories — see "Exclude mechanisms outside of Git" below.
set('git_drift_skip_worktree_paths', [
    'public/.htaccess',
]);

Available Tasks

Task Description
git-drift:init Initialize Git tracking in the release directory after deployment
git-drift:check Check for drift before deployment — warns or aborts
git-drift:status Show drift status without deploying
git-drift:reset Rebuild the baseline of the current release without deploying

Run the status check manually at any time:

dep git-drift:status production

Resetting the baseline

git-drift:init never fails a deployment. When it cannot build the baseline it prints

⚠ Git drift init failed (non-fatal): ...

and the deploy continues. The release is then left without a baseline, and every following deployment skips git-drift:check with a notice — drift tracking never starts, quietly. Watch for that line in the deploy log: it carries the underlying cause, typically an SSH host key the server has not accepted for the repository host, a missing deploy key, or an unreachable remote.

Once the cause is fixed, build the baseline without waiting for the next deployment:

dep git-drift:reset production

This re-fetches the deployed branch and rebuilds the baseline from it. It does not accept existing server-side changes — only the baseline is rebuilt, never the working tree, so files that already differ keep showing up as drift afterwards.

Example Output

When drift is detected:

⚠ Server drift detected:

 public/index.php | 5 +++--
 config/system/settings.php | 12 ++++++++----

Untracked files added on server:
public/fileadmin/direct-upload.zip

These changes were made directly on the server.
They will be LOST after this deployment.

Continue deployment and discard changes? [y/N]

How it works

After each deployment, git-drift:init runs git init in the release directory, fetches the deployed branch with --depth=1, and sets FETCH_HEAD as the baseline via git reset. Any subsequent server-side file modifications will appear as changes relative to this baseline.

On the very first deployment after adding this recipe, there is no previous release to compare against, so git-drift:check skips with a notice instead of checking anything. Drift detection becomes active starting with the deployment after that.

Paths listed in git_drift_ignore_paths are written to .git/info/exclude (local gitignore, does not modify project files) so generated, untracked directories are excluded from drift detection.

Shared directories and export-ignored files

Deployer replaces shared_dirs/shared_files paths with symlinks into shared/, so a tracked file underneath one (e.g. a .gitkeep placeholder in a shared uploads directory) will always differ from its Git blob. Files marked export-ignore in .gitattributes are absent from the deployed release entirely and would otherwise look permanently "deleted". Both cases are detected automatically — from Deployer's own shared_dirs/shared_files config and from a git archive comparison — and marked with Git's --skip-worktree bit, so they never show up as drift. The shared symlinks themselves are also appended to .git/info/exclude automatically, the same file git_drift_ignore_paths writes to. No project-specific configuration is needed for either case.

Use git_drift_skip_worktree_paths only for tracked files outside of shared dirs that are still expected to be rewritten on the server, such as .htaccess rules regenerated by an install routine.

Exclude mechanisms outside of Git

The detection above knows exactly two sources: Deployer's own shared_dirs/shared_files, and export-ignore in .gitattributes, via the git archive comparison. It cannot see any other mechanism that keeps files off the server — an rsync --exclude list contributed by another recipe, a build step that removes directories after upload, a deploy-time cleanup task. Files dropped that way are tracked at HEAD and present in the archive but absent from the release, so they show up as permanently "deleted".

Mirror every such source into one of the two mechanisms the recipe can see:

  • git_drift_skip_worktree_paths — the direct route. It takes a tracked file path or a directory standing for everything tracked below it, resolved the same way as a shared path: Build and Build/ both cover Build/src/app.js. Nothing in the project changes, the entry only affects the drift check.
  • .gitattributes export-ignore — the right place when the files should be absent from any export of the repository, not just from this deployment. git archive then omits them and the automatic detection above picks them up without further configuration.

An entry that covers no tracked file at all is reported and skipped rather than acted on:

⚠ git_drift_skip_worktree_paths covers no tracked file and was ignored: public/.htacces

A typo therefore shows up as a warning instead of quietly having no effect.

If a project draws its exclude list from several places, check all of them before assuming the detection is at fault — a partially mirrored list looks exactly like a bug in the drift check.

Development

The skip-worktree decision logic lives in GitDriftIndexPlanner, a pure class with no Git or Deployer dependency, so it is covered by unit tests without a real repository.

ddev composer install
ddev exec vendor/bin/phpunit
ddev exec vendor/bin/phpstan analyse
ddev exec vendor/bin/php-cs-fixer fix --dry-run --diff

License

MIT — Oliver Thiele