GIT2FTP deploys the ordered changes from a Git release branch to an FTP, FTPS, or SFTP location. The deployment direction is strictly Git to remote: the product never writes to the Git server, never imports remote changes into Git, and never reconciles unrelated remote files.
This repository implements the specification in specification.md. The implemented scope is the
command-line application and the complete synchronization engine (configuration, Git adapter,
WinSCP transfer engine, state, audit, logging, locking, retry, and recovery). The WPF desktop
application, the Windows service host, and the Inno Setup installer are specified but not yet
implemented; every operational action is available through GIT2FTP.Cli.exe.
- Windows 11 x64 or Windows Server 2022 x64 or later.
- .NET 10 SDK (pinned via
global.json) to build. - Git for Windows 2.40.0 or later installed and on the machine
PATHof the running identity. - The bundled
WinSCP.exe/WinSCPnet.dll(restored automatically with theWinSCPNuGet package). - Git credentials must come from the locally installed Git credential configuration
(Git Credential Manager or another non-interactive helper). GIT2FTP never puts credentials in
URLs, arguments, or logs, and runs every Git command with
GIT_TERMINAL_PROMPT=0.
dotnet build GIT2FTP.slnx
dotnet test GIT2FTP.slnx --filter "Category!=Live" # CI-safe suite (unit + component)
dotnet test tests/GIT2FTP.IntegrationTests # live tests; requires integration.config.jsonThe live integration tests push commits to the configured Git repository and deploy files to the
configured FTP endpoint. They are excluded from CI by the Live trait. Their configuration lives in
tests/GIT2FTP.IntegrationTests/integration.config.json (gitignored; copy
integration.config.template.json and fill in the credentials). The supplied secrets must never be
committed to the repository.
GIT2FTP keeps mutable state beside the executable:
<installation-root>\
GIT2FTP.Cli.exe
WinSCP.exe / WinSCPnet.dll
git2ftp.json (default configuration; override with --config)
cache\ per-pair bare Git caches (cache\<pair-sha1>.git)
state\ per-pair deployment state + backups + recovery markers
audit\ append-only monthly JSONL audit with a SHA-256 hash chain
logs\ combined human-readable daily logs (30-day retention)
locks\ per-pair exclusive lock files
runtime\ content-addressed blob materialization
The --root <path> option (or the GIT2FTP_ROOT environment variable) redirects all mutable
directories, which is useful for testing and for running multiple instances.
See schemas/git2ftp.schema.json (Draft 2020-12) and the example in specification.md Appendix C.
Essentials:
pairsis an ordered array; array order is execution order. IDs are unique case-insensitively.git.urlmust be an absolute HTTPS URL without user information. A test-only opt-in,git.allowInsecureGitUrl: true, permitshttp://or local paths and produces a prominent warning.git.branchismainormaster.remote.protocolisftp,ftps(withftpsMode: explicit|implicit), orsftp. Password authentication is mandatory; empty passwords are rejected.remote.ftpDataConnectionModeispassive(default) oractive.remote.acceptInvalidTlsCertificate(FTPS only) accepts any TLS certificate and logs a high-severity warning on every connection.remote.acceptUnknownSshHostKey(SFTP only) uses WinSCP's accept-new policy: the first observed host key is cached under the running identity and a later changed key fails the pair.${NAME}environment placeholders may be used in every string-valued Git/remote field.$${NAME}represents the literal text${NAME}. Missing or recursive placeholders are rejected.- Retry overrides must guarantee a minimum retry span of 15 minutes; the default policy spans 18 minutes (waits of 30, 60, 120, 240, 300, 300, 300 seconds with ±20% jitter).
- Plain FTP transmits credentials and content unencrypted. Prefer FTPS or SFTP.
- Plaintext passwords in
git2ftp.jsonare a deliberate product decision. Restrict the file withconfig protect-secrets(NTFS ACL hardening for SYSTEM, Administrators, and the service identity; values are not encrypted) and with${ENV}placeholders. - Accepting invalid TLS certificates or unknown SSH host keys enables server impersonation. Both opt-ins are pair-specific and produce warnings on every run.
- Multi-file commits are best-effort atomic, not ACID. Per-file promotion is atomic where the server supports rename; a failure between files leaves the remote in a mixed state that the next run replays idempotently. There is no remote backup.
- First-parent chain. The deployed sequence is the branch's first-parent history. Merge commits deploy their net effect against the first parent; side-branch commits are not deployed separately.
- Baseline. With no state and no
git.initialBaselineCommit, the current tip becomes the baseline and nothing is uploaded during that first run — an existing site is never overwritten by accident. A configured baseline must belong to the branch history; its contents are assumed to be deployed already. Operators who want an initial full upload provide an empty-root commit as the baseline or trigger the reconciliation procedure deliberately. - Force-push reconciliation. After every fetch GIT2FTP checks
git merge-base --is-ancestor <lastSuccessfulCommit> <tip>. If the recorded commit is no longer an ancestor (rewritten history), GIT2FTP performs a Git-owned-path reconciliation at the new tip: every current tracked file is uploaded, only paths tracked in the previous manifest but absent from the tip are deleted, and every unrelated remote path is preserved. - Delta mapping. Added/modified files are staged, verified, and promoted; deleted files are deleted after all promotions (a missing target is idempotent success); renames appear as delete+add; type changes are decomposed; mode-only changes upload nothing; symbolic links are uploaded as ordinary files containing the link-target blob.
- Reserved paths. The remote
<remote-root>/.sync/tree is owned by GIT2FTP (staging and markers). A repository that tracks a root.syncentry is rejected..permissionsis a control file: it is parsed but never uploaded.
A UTF-8 file at the repository root with one relative/path=MODE entry per non-empty line, split at
the last = (so filenames may contain =). MODE is a three- or four-digit octal number
(000–7777). Paths are relative, use /, and may not target .sync or .permissions. If the
file exists at the deployed commit, its entries are re-applied at the end of every successful commit
and every no-new-commit run. A missing target is a non-retryable failure; if the server cannot set
or report Unix permissions, the commit fails rather than silently ignoring the directive.
lastSuccessfulCommitadvances only after staging, verification, promotion, deletion, permissions, the pending-state audit record, and the state write all succeeded.- A recovery marker is flushed before the first promotion. If the terminal audit write fails after the state write, the next run verifies the state and appends a recovered terminal record.
- A failed commit is replayed completely on the next run: uploads replace the desired content again and deletions accept "already missing". Later commits are blocked until the failed one succeeds.
- Pair state is written atomically (temp file, flush-to-disk,
File.Replace) with a retained backup; a corrupt primary is restored from the backup with a warning. - A per-pair lock file (
locks\<pair-sha1>.lock) is held for the whole pair run. A second caller gets exit code7 (PairBusy)and the owner's diagnostics; a killed process releases the lock automatically.
logs\GIT2FTP-yyyyMMdd.log— one combined chronological stream for all processes, retained for the current UTC day plus the previous 29 days. Every line is redacted centrally.audit\deployments-yyyy-MM.jsonl— append-only, tamper-evident (SHA-256 record chain plus a chain-head pointer), retained indefinitely.historyverifies the chain while reading and reports corruption distinctly.- Redaction policy: known secrets keep at most their first three characters followed by eight
asterisks (
abc********); URL user-info, nested exception messages, and server echoes are redacted before any sink.
GIT2FTP.Cli.exe <command> [options]
Commands:
validate [--pair <id>] parse + schema + semantic checks, no network
prerequisites [--pair <id>] validate + OS/Git/WinSCP/writability/space checks
sync [--pair <id>] deploy; without --pair runs enabled pairs in order
status [--pair <id>] local state only, no network
test-connections [--pair <id>] [--git-only|--remote-only] [--verify-write]
history [--pair <id>] [--since <ISO>] [--limit <n>]
config protect-secrets [--account <DOMAIN\user|.\\user|gMSA$>]
Global options:
--config <absolute-path> --output <text|json> --no-color
--verbosity <quiet|normal|detailed> --correlation-id <guid>
--root <path> --help --version
--output json emits newline-delimited JSON: one versioned event per line followed by exactly one
terminal RunCompleted event. stdout carries protocol events only; diagnostics go to stderr,
redacted.
| Code | Meaning | Code | Meaning |
|---|---|---|---|
| 0 | Success | 11 | RemoteAuthenticationError |
| 1 | PartialFailure (multi-pair) | 12 | RemoteTrustError |
| 2 | InvalidArguments | 13 | RemoteProtocolError |
| 3 | ConfigurationParseError | 14 | RemotePathError |
| 4 | ConfigurationValidationError | 15 | TransferFailed |
| 5 | PrerequisiteMissing | 16 | PermissionsError |
| 6 | PairNotFoundOrDisabled | 17 | StateError |
| 7 | PairBusy | 18 | AuditError |
| 8 | GitAuthenticationError | 19 | ServiceControlError |
| 9 | GitRepositoryError | 20 | Cancelled |
| 10 | UnsupportedGitFeature | 21 | UnexpectedError |
Git could not authenticate non-interactively(exit 8) — the running identity has no usable credential for the repository. Rungit credential approve(orgit fetchonce interactively) as that identity so the credential manager can supply it, then retry.Branch '...' was not found at the configured origin(exit 9) — the configured branch does not exist;git.branchaccepts onlymainormaster.Pair '...' is busy(exit 7) — another CLI/service/WPF process holds the pair lock; the error names the owning PID and correlation ID.UnsupportedGitFeature(exit 10) — the commit contains a submodule,.gitmodules, an LFS filter/pointer, an unsupported mode, or a reserved root.syncpath. Remove it from the branch.TransferFailed(exit 15) — a retryable transfer exhausted its attempts (default: 8 attempts spanning ≥ 15 minutes) or a non-retryable file operation failed. The next run replays the commit.PermissionsError(exit 16) —.permissionsis malformed, targets a missing file, or the server cannot apply/verify the requested mode.- Audit corruption reported by
history(exit 18) — the audit chain or chain head does not match; investigate theaudit\directory before trusting the deployment history.
Per the specification's phased plan, the following components are specified but not part of this
build: the WPF desktop application (Phase H), the Windows service host with the completion-based
scheduler (Phase G), and the Inno Setup installer with the elevated service-account helper
(Phase I). The CLI already exposes the service command grammar and returns
ServiceControlError (19) until the service host lands.