Skip to content

Repository files navigation

GIT2FTP

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.

Requirements

  • 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 PATH of the running identity.
  • The bundled WinSCP.exe / WinSCPnet.dll (restored automatically with the WinSCP NuGet 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.

Building and testing

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.json

The 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.

Installation layout

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.

Configuration

See schemas/git2ftp.schema.json (Draft 2020-12) and the example in specification.md Appendix C. Essentials:

  • pairs is an ordered array; array order is execution order. IDs are unique case-insensitively.
  • git.url must be an absolute HTTPS URL without user information. A test-only opt-in, git.allowInsecureGitUrl: true, permits http:// or local paths and produces a prominent warning.
  • git.branch is main or master.
  • remote.protocol is ftp, ftps (with ftpsMode: explicit|implicit), or sftp. Password authentication is mandatory; empty passwords are rejected.
  • remote.ftpDataConnectionMode is passive (default) or active.
  • 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).

Security warnings (read before using plain FTP)

  • Plain FTP transmits credentials and content unencrypted. Prefer FTPS or SFTP.
  • Plaintext passwords in git2ftp.json are a deliberate product decision. Restrict the file with config 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.

Deployment semantics

  • 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 .sync entry is rejected. .permissions is a control file: it is parsed but never uploaded.

.permissions

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 (0007777). 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.

Recovery and idempotency

  • lastSuccessfulCommit advances 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 code 7 (PairBusy) and the owner's diagnostics; a killed process releases the lock automatically.

Logs and audit

  • 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. history verifies 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.

CLI reference

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.

Exit codes (stable API)

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

Troubleshooting

  • Git could not authenticate non-interactively (exit 8) — the running identity has no usable credential for the repository. Run git credential approve (or git fetch once 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.branch accepts only main or master.
  • 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 .sync path. 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).permissions is 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 the audit\ directory before trusting the deployment history.

Not yet implemented

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages