Skip to content

fix(git): system-git fallback for Azure DevOps repositories (#288) - #292

Closed
consooo wants to merge 1 commit into
ConfigButler:mainfrom
consooo:fix/288-ado-system-git-fallback
Closed

fix(git): system-git fallback for Azure DevOps repositories (#288)#292
consooo wants to merge 1 commit into
ConfigButler:mainfrom
consooo:fix/288-ado-system-git-fallback

Conversation

@consooo

@consooo consooo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Pull Request

go-git v5 strips MultiACK/MultiACKDetailed from its capability advertisement. ADO rejects any upload-pack request that omits multi_ack with HTTP 400 (TF401041: "Clients must support multi-ack.").

Proper fix: go-git PR #1204 (full multi_ack implementation) is targeted for v6.0.0, which is still in alpha as of 2026-07-28. Drop this fallback and upgrade to go-git v6 once a stable release lands.

Description

This change adds a per-URL system-git fallback: any remote whose URL contains dev.azure.com, visualstudio.com, or ssh.dev.azure.com is routed through exec.Command("git", ...) instead of go-git. All other providers continue to use the existing go-git path unchanged.

Three entry points now delegate to system git for ADO URLs:

  • SmartFetch → systemGitSmartFetch (git ls-remote + git fetch)
  • PushAtomic → systemGitPushAtomic (git ls-remote + git push --force-with-lease)
  • CheckRepo → systemGitCheckRepo (git ls-remote --symref)

Credentials are never passed via argv:

  • BasicAuth: GIT_CONFIG_GLOBAL with http.extraHeader = Authorization: Basic
  • TokenAuth: GIT_CONFIG_GLOBAL with http.extraHeader = Authorization: Bearer
  • SSH: GIT_SSH_COMMAND with -i and optional known_hosts

Dockerfile: adds a git-bundle Alpine stage that collects git and all its musl shared-library dependencies via ldd, then copies them into the distroless final image. The distroless/static:debug base already includes /bin/sh (busybox), which is required to execute the GIT_ASKPASS script.

Type of Change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test coverage improvement

Testing

  • Unit tests pass locally
  • Integration tests pass locally
  • Manual testing completed (if applicable)
  • New tests added for new functionality

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules - no dependent changes

Related Issues

Closes #288

Summary by CodeRabbit

  • New Features
    • Added Azure DevOps repository support for repository checks, fetching, and atomic pushes using enhanced compatibility logic.
    • Added a system-git fallback to ensure reliable Azure DevOps operations in the runtime environment.
  • Documentation
    • Updated configuration docs with Azure DevOps HTTPS (PAT/basic/bearer) and SSH setup guidance.
  • Bug Fixes
    • Improved Azure DevOps URL detection to route check/fetch/push correctly.
    • Strengthened SSH key handling and secure credential/temp file behavior.
  • Tests
    • Added/updated unit tests covering Azure DevOps git fallback, auth handling, and parsing.

@consooo
consooo requested a review from sunib as a code owner July 30, 2026 08:30
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@consooo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c04544f0-1522-464a-b58a-c86af80d7efe

📥 Commits

Reviewing files that changed from the base of the PR and between 59ed890 and 2c5d321.

📒 Files selected for processing (9)
  • Dockerfile
  • docs/configuration.md
  • internal/git/ado_system_git.go
  • internal/git/ado_system_git_test.go
  • internal/git/credentials_test.go
  • internal/git/git.go
  • internal/git/git_atomic_push.go
  • internal/git/git_smart_fetch.go
  • internal/ssh/auth.go
📝 Walkthrough

Walkthrough

Azure DevOps remotes now route fetch, push, and repository checks through system git. SSH credentials expose material for subprocess use, the distroless image bundles git and dependencies, and configuration documentation describes ADO credentials and requirements.

Changes

Azure DevOps system Git support

Layer / File(s) Summary
SSH authentication material
internal/ssh/auth.go, internal/git/credentials_test.go
KeyAuth preserves go-git SSH authentication while exposing private-key PEM and known_hosts data for system git, with updated credential assertions.
System git environment and command execution
internal/git/ado_system_git.go, internal/git/ado_system_git_test.go
ADO URL detection, HTTP and SSH environment setup, temporary credential cleanup, command execution, remote parsing, and security-focused tests are added.
ADO fetch, push, and repository checks
internal/git/ado_system_git.go, internal/git/git_smart_fetch.go, internal/git/git_atomic_push.go, internal/git/git.go
ADO operations use system git for remote inspection, smart fetch, atomic push, branch handling, and repository metadata while non-ADO paths remain unchanged.
Runtime packaging and configuration
Dockerfile, docs/configuration.md
The final image includes system git, SSH tooling, remote helpers, and shared libraries; documentation defines ADO HTTPS and SSH credential configuration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SmartFetch
  participant systemGitSmartFetch
  participant ADORepository
  participant LocalRepository
  SmartFetch->>systemGitSmartFetch: route ADO remote
  systemGitSmartFetch->>ADORepository: ls-remote --symref
  ADORepository-->>systemGitSmartFetch: refs and default branch
  systemGitSmartFetch->>LocalRepository: git fetch --prune
  LocalRepository-->>SmartFetch: updated repository state
Loading

Suggested reviewers: sunib

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: a system-git fallback for Azure DevOps repositories.
Description check ✅ Passed The PR description covers the change, type, testing, checklist, and related issue; only optional sections are omitted.
Linked Issues check ✅ Passed The PR implements the requested ADO workaround so fetches use system git and should avoid the multi_ack HTTP 400 failure.
Out of Scope Changes check ✅ Passed The Docker, SSH auth, and documentation changes all support the Azure DevOps fallback and are within scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitguardian

gitguardian Bot commented Jul 30, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
internal/git/ado_system_git.go (3)

33-38: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Substring matching can misroute non-ADO remotes.

strings.Contains matches any URL whose path or hostname merely embeds these strings (e.g. https://git.example.com/mirrors/dev.azure.com-backup.git). Parsing the URL and matching the host suffix would be exact; the ssh.dev.azure.com check is also redundant with dev.azure.com.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/ado_system_git.go` around lines 33 - 38, Update IsADOURL to
parse rawURL and validate the parsed hostname rather than using substring
checks. Match only hosts equal to dev.azure.com or visualstudio.com, or hosts
ending with those domains, and remove the redundant ssh.dev.azure.com condition
while preserving false results for embedded path or hostname text.

106-127: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add -o IdentitiesOnly=yes to GIT_SSH_COMMAND.

Without it ssh may still offer agent or default ~/.ssh/id_* identities before the provided key, which makes auth failures confusing and can authenticate with the wrong identity.

🔒 Proposed fix
-		sshCmd := "ssh -i '" + keyFile + "' -o BatchMode=yes"
+		sshCmd := "ssh -i '" + keyFile + "' -o IdentitiesOnly=yes -o BatchMode=yes"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/ado_system_git.go` around lines 106 - 127, Update the SSH
command assembled in the *sshpkg.KeyAuth branch of the systemGitEnv setup to
include the IdentitiesOnly=yes option alongside the provided key option,
ensuring authentication uses only that key while preserving the existing
known-hosts and cleanup behavior.

350-360: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Use an empty lease for new branch creation.

When remoteBranchHash is empty, branch creation runs without a concurrency guard and can clobber a remote branch created by another actor. Use --force-with-lease=<branchShort>: so Git rejects the push if the ref already exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/ado_system_git.go` around lines 350 - 360, Update the push
argument construction around remoteBranchHash so the empty-hash path also
appends --force-with-lease for branchShort with an empty expected remote value.
Preserve the existing non-empty remoteBranchHash lease and ensure both new and
existing branch pushes use concurrency protection.
internal/ssh/auth.go (1)

83-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The crypto.PrivateKey assertion is a no-op.

crypto.PrivateKey is defined as any, so the type assertion on Line 94 can never fail — the real type filtering happens inside gossh.MarshalPrivateKey (which supports *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, but not the DSA keys ParseRawPrivateKey can also return). Dropping the check keeps the marshal error as the single source of truth.

♻️ Proposed simplification
-	privKey, ok := rawKey.(crypto.PrivateKey)
-	if !ok {
-		return nil, errors.New("parsed SSH key does not implement crypto.PrivateKey")
-	}
-	block, err := gossh.MarshalPrivateKey(privKey, "")
+	block, err := gossh.MarshalPrivateKey(rawKey, "")
 	if err != nil {
-		return nil, fmt.Errorf("marshal unencrypted SSH private key: %w", err)
+		return nil, fmt.Errorf("marshal unencrypted SSH private key (unsupported key type?): %w", err)
 	}

(crypto import then becomes unused.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ssh/auth.go` around lines 83 - 103, Remove the no-op
crypto.PrivateKey type assertion and its associated error handling from
decryptPrivateKeyPEM, passing the parsed rawKey directly to
gossh.MarshalPrivateKey. Remove the now-unused crypto import and retain
MarshalPrivateKey’s error as the sole unsupported-key failure path.
Dockerfile (1)

56-77: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a build-time sanity check so a missing shared library fails the build, not the cluster.

ldd ... 2>/dev/null || true semantics mean a partially-harvested bundle produces an image whose git only fails at reconcile time. A quick verification in the same stage catches it during build.

♻️ Proposed addition
                 esac; \
             done; \
-    done
+    done; \
+    # Fail the build if anything is still unresolved once relocated.
+    for f in /bundle/bin/git /bundle/bin/ssh; do \
+        ldd "$f" | grep -q 'not found' && { echo "missing deps for $f"; exit 1; }; \
+        true; \
+    done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 56 - 77, Add a build-time verification step to the
existing Dockerfile RUN block after harvesting dependencies, exercising the
bundled /bundle/bin/git and /bundle/bin/ssh with the bundled library paths. Make
the check fail the image build when either binary cannot start because a
required shared library is missing, while preserving the current bundle layout
and copy logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/configuration.md`:
- Around line 98-107: Update the recommended Azure DevOps PAT configuration in
the documentation to use Basic authentication with username and password
credentials, representing the PAT as the password. Remove bearerToken from this
PAT example and add or relocate it to a separate Entra-ID/OAuth token example,
while preserving the existing SSH guidance.

In `@internal/git/ado_system_git_test.go`:
- Around line 47-48: Replace the credential-like fixture values in
TestNewSystemGitEnv_BasicAuth and the related token fixture with clearly
non-secret test strings, updating every corresponding assertion including the
check near the BasicAuth password assertion. Keep the test behavior unchanged
while ensuring the GitGuardian scanner no longer classifies the literals as
credentials.
- Around line 240-254: Update assertSSHFilesNotWorldReadable to normalize shell
quoting from each sshCmd field before filtering and calling os.Stat, so
single-quoted /tmp/reverser-git paths resolve to actual files. Preserve the
existing path filtering and permission assertion, but do not silently skip stat
failures for candidate SSH files; report them through the test instead.

In `@internal/git/ado_system_git.go`:
- Around line 76-104: Update both the *http.BasicAuth and *http.TokenAuth
branches in the systemGitEnv construction to scope the git configuration’s
extraHeader to repoURL instead of using the global [http] section. Use the
already available repoURL when formatting each config, while preserving the
existing authorization values, file permissions, cleanup behavior, and
environment setup.

In `@internal/git/git_smart_fetch.go`:
- Around line 37-39: Guard all origin URL access against an empty URLs slice by
adding and reusing a helper such as originURL(repo) (string, error). In
internal/git/git_smart_fetch.go#L37-L39 and
internal/git/git_atomic_push.go#L208-L211, only perform ADO routing when an
origin URL exists; otherwise preserve the non-ADO/error path. In
internal/git/ado_system_git.go#L229-L233 within systemGitSmartFetch and
`#L309-L313` within systemGitPushAtomic, return a wrapped error when origin has no
URL instead of indexing the slice.

---

Nitpick comments:
In `@Dockerfile`:
- Around line 56-77: Add a build-time verification step to the existing
Dockerfile RUN block after harvesting dependencies, exercising the bundled
/bundle/bin/git and /bundle/bin/ssh with the bundled library paths. Make the
check fail the image build when either binary cannot start because a required
shared library is missing, while preserving the current bundle layout and copy
logic.

In `@internal/git/ado_system_git.go`:
- Around line 33-38: Update IsADOURL to parse rawURL and validate the parsed
hostname rather than using substring checks. Match only hosts equal to
dev.azure.com or visualstudio.com, or hosts ending with those domains, and
remove the redundant ssh.dev.azure.com condition while preserving false results
for embedded path or hostname text.
- Around line 106-127: Update the SSH command assembled in the *sshpkg.KeyAuth
branch of the systemGitEnv setup to include the IdentitiesOnly=yes option
alongside the provided key option, ensuring authentication uses only that key
while preserving the existing known-hosts and cleanup behavior.
- Around line 350-360: Update the push argument construction around
remoteBranchHash so the empty-hash path also appends --force-with-lease for
branchShort with an empty expected remote value. Preserve the existing non-empty
remoteBranchHash lease and ensure both new and existing branch pushes use
concurrency protection.

In `@internal/ssh/auth.go`:
- Around line 83-103: Remove the no-op crypto.PrivateKey type assertion and its
associated error handling from decryptPrivateKeyPEM, passing the parsed rawKey
directly to gossh.MarshalPrivateKey. Remove the now-unused crypto import and
retain MarshalPrivateKey’s error as the sole unsupported-key failure path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5941da54-d743-49dc-9528-cbe7c6d1c604

📥 Commits

Reviewing files that changed from the base of the PR and between aed4af9 and 031e904.

📒 Files selected for processing (9)
  • Dockerfile
  • docs/configuration.md
  • internal/git/ado_system_git.go
  • internal/git/ado_system_git_test.go
  • internal/git/credentials_test.go
  • internal/git/git.go
  • internal/git/git_atomic_push.go
  • internal/git/git_smart_fetch.go
  • internal/ssh/auth.go

Comment thread docs/configuration.md Outdated
Comment thread internal/git/ado_system_git_test.go Outdated
Comment thread internal/git/ado_system_git_test.go
Comment thread internal/git/ado_system_git.go
Comment thread internal/git/git_smart_fetch.go Outdated
@consooo
consooo force-pushed the fix/288-ado-system-git-fallback branch 2 times, most recently from 4bc0aca to 2ac481c Compare July 30, 2026 08:52
@sunib

sunib commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Thanks @consooo for raising this! This is one of these things that you don't find out if you don't try it. It's a bit of a bummer tbh that it doesnt work with ADO so that really requires a fix.

I'm going to be honest: I dislike calling git as external tool: go-git is so elegent as in that it's not going to require specials in the Dockerfile for example. Did you consider to live on the edge and try go-git version 6? I have so many tests that I would be willing to take the risk on working with a beta release of their side: especially if that would give a more clean approach.

I'm not saying no directly: but it's a thing that I would like to weigh as well!

And another though that pops up: I remember that I took the time to really make it effecient by diving a bit deeper in the git specifics, it has been a few months since then so I do need some time to see why that was. Perhaps we can also add a flag to bypass the advacned pushing: and just do a more simple out of the box push. One would hope that it then works as well: it simply can't be true that ADO doesnt work with go-git v5 can it?

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

@consooo

consooo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the quick reply @sunib. Yeah, I was quite surprised as well. I tested with a private github repo and everything was fine. Switching to ADO, I encountered these weird 400s. This seems to be a well known annoying shortcoming, I would put the blame squarely on ADO. It's been there for 6 years!
go-git/go-git#64
fluxcd/source-controller#104 (comment)

I'm going to be honest: I dislike calling git as external tool: go-git is so elegent as in that it's not going to require specials in the Dockerfile for example. Did you consider to live on the edge and try go-git version 6? I have so many tests that I would be willing to take the risk on working with a beta release of their side: especially if that would give a more clean approach.

I agree, this is not ideal. Most projects add a workaround either using system git or switch to or add git2go lib. I also considered just using go-git v6 or switching to git2go. But both require quite a bit of refactoring and go-git v6 has been in alpha for a while. There is no telling when it will be stable and it will lead to lots of work adapting to API changes. It might not be too bad if we simplified the git flow but with the current status, I think this would be quite the hassle.

Perhaps we can also add a flag to bypass the advacned pushing: and just do a more simple out of the box push. One would hope that it then works as well: it simply can't be true that ADO doesnt work with go-git v5 can it?

Even a git fetch won't work I'm afraid.

System git is the least painful way imho. Its only used in the ADO path, not for other repos. I noted that this is not the correct fix. The correct fix is to lift to v6 and this is only a temporary fix until it is released. Adding these binaries is not nice. But I think its preferable to doing a migration to go-git v6 and then keeping up with the changes until a stable version is released. This would be much more work.

@consooo
consooo force-pushed the fix/288-ado-system-git-fallback branch from 2ac481c to 59ed890 Compare July 30, 2026 09:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
internal/git/ado_system_git_test.go (1)

92-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the negative case for the token guard, and consider coverage for the three ADO operations.

newSystemGitEnv rejects tokens containing \n/\r (ado_system_git.go Lines 96-99) but no test exercises it, and systemGitSmartFetch/systemGitPushAtomic/systemGitCheckRepo are entirely untested — a local bare repo as origin plus a stubbed IsADOURL-matching URL would cover the ls-remote/refspec/force-with-lease logic.

💚 Suggested test
func TestNewSystemGitEnv_TokenAuth_RejectsNewline(t *testing.T) {
	if _, err := newSystemGitEnv(
		"https://dev.azure.com/org/proj/_git/repo",
		&http.TokenAuth{Token: "test-token\nextraHeader: injected"},
	); err == nil {
		t.Fatal("expected error for token containing a newline")
	}
}

As per coding guidelines: "Cover new code with positive and negative tests, add integration tests for complex workflows".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/ado_system_git_test.go` around lines 92 - 130, The tests cover
successful token authentication but not the newline-injection guard or the ADO
Git operation workflows. Add a negative test alongside
TestNewSystemGitEnv_TokenAuth that verifies newSystemGitEnv rejects tokens
containing newline or carriage-return characters, and add integration coverage
for systemGitSmartFetch, systemGitPushAtomic, and systemGitCheckRepo using a
local bare repository as origin with an IsADOURL-matching URL to exercise
ls-remote, refspec, and force-with-lease behavior.

Source: Coding guidelines

internal/ssh/auth.go (1)

94-97: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead type assertion — crypto.PrivateKey is any.

rawKey.(crypto.PrivateKey) can never fail, so the error branch is unreachable and the crypto import exists only for it. Drop it and let MarshalPrivateKey report unsupported types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ssh/auth.go` around lines 94 - 97, In the key handling flow, remove
the `crypto.PrivateKey` type assertion and its unreachable error branch, along
with the now-unused `crypto` import. Pass `rawKey` directly to
`MarshalPrivateKey` so that function reports unsupported key types.
Dockerfile (1)

58-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider scanelf/apk info over ldd scraping, and note the attack-surface tradeoff.

The ldd-plus-awk harvest works but silently tolerates copy failures (|| true), so a missing dependency surfaces only at runtime. scanelf --needed --nobanner or letting apk resolve the closure into a staging root is more deterministic. Separately, bundling git, ssh and a busybox shell removes most of the benefit of a distroless base — worth documenting the tradeoff (and the removal trigger once go-git v6 is stable) next to this stage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 58 - 77, Replace the ldd/awk dependency harvesting
in the bundle stage with deterministic dependency resolution using scanelf
--needed --nobanner or apk’s staging-root closure, and stop silently ignoring
missing dependency copies. Add a concise note beside this stage documenting the
expanded attack surface from bundling git, ssh, and a busybox shell, including
the removal trigger when go-git v6 is stable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/git/ado_system_git.go`:
- Around line 59-60: Update the doc comment for the repoURL parameter near the
URL-scoped http section to remove the stale statement that it is not used by the
current implementation, while retaining the forward-compatibility and per-host
credential-scoping context.
- Around line 167-169: Sanitize credential-bearing URLs before constructing the
error in the cmd.Run failure path of the relevant git execution method. Redact
URL userinfo, including embedded ADO PATs, from both args and stderr before
passing them to fmt.Errorf, while preserving the existing error context and
wrapping behavior.
- Around line 34-38: Update IsADOURL to parse the input as a URL, require an
https or ssh scheme, and match dev.azure.com or visualstudio.com only against
the parsed hostname rather than arbitrary substrings; return false for malformed
URLs and unsafe values such as ext::sh and option-prefixed strings. Add the two
specified negative cases to TestIsADOURL, and ensure systemGitCheckRepo does not
pass rejected raw repoURL values to git.

In `@internal/ssh/auth.go`:
- Around line 68-71: Update the SSH authentication construction around
decryptPrivateKeyPEM so it does not re-serialize decrypted keys through
gossh.MarshalPrivateKey for every provider. Preserve the parsed key/signer for
go-git-based providers, and perform raw unencrypted PEM serialization only in
newSystemGitEnv or the system-git fallback that requires it, allowing supported
and unsupported key types to continue through other SSH providers.

---

Nitpick comments:
In `@Dockerfile`:
- Around line 58-77: Replace the ldd/awk dependency harvesting in the bundle
stage with deterministic dependency resolution using scanelf --needed --nobanner
or apk’s staging-root closure, and stop silently ignoring missing dependency
copies. Add a concise note beside this stage documenting the expanded attack
surface from bundling git, ssh, and a busybox shell, including the removal
trigger when go-git v6 is stable.

In `@internal/git/ado_system_git_test.go`:
- Around line 92-130: The tests cover successful token authentication but not
the newline-injection guard or the ADO Git operation workflows. Add a negative
test alongside TestNewSystemGitEnv_TokenAuth that verifies newSystemGitEnv
rejects tokens containing newline or carriage-return characters, and add
integration coverage for systemGitSmartFetch, systemGitPushAtomic, and
systemGitCheckRepo using a local bare repository as origin with an
IsADOURL-matching URL to exercise ls-remote, refspec, and force-with-lease
behavior.

In `@internal/ssh/auth.go`:
- Around line 94-97: In the key handling flow, remove the `crypto.PrivateKey`
type assertion and its unreachable error branch, along with the now-unused
`crypto` import. Pass `rawKey` directly to `MarshalPrivateKey` so that function
reports unsupported key types.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 85721021-e4d2-40eb-bb40-832cb3147c93

📥 Commits

Reviewing files that changed from the base of the PR and between 031e904 and 59ed890.

📒 Files selected for processing (9)
  • Dockerfile
  • docs/configuration.md
  • internal/git/ado_system_git.go
  • internal/git/ado_system_git_test.go
  • internal/git/credentials_test.go
  • internal/git/git.go
  • internal/git/git_atomic_push.go
  • internal/git/git_smart_fetch.go
  • internal/ssh/auth.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/configuration.md

Comment thread internal/git/ado_system_git.go
Comment thread internal/git/ado_system_git.go Outdated
Comment thread internal/git/ado_system_git.go
Comment thread internal/ssh/auth.go Outdated
Comment on lines 68 to 71
unencryptedPEM, err := decryptPrivateKeyPEM([]byte(privateKey), []byte(password))
if err != nil {
return nil, fmt.Errorf("failed to prepare SSH private key for system-git: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm which key types x/crypto/ssh MarshalPrivateKey accepts in the pinned version.
ver=$(rg -N 'golang.org/x/crypto' go.mod | head -1)
echo "go.mod: $ver"
fd -t f 'go.sum' -x rg -N 'golang.org/x/crypto v' {} \; | head -3

Repository: ConfigButler/gitops-reverser

Length of output: 472


🌐 Web query:

golang.org/x/crypto/ssh MarshalPrivateKey supported key types ed25519.PrivateKey value pointer

💡 Result:

The golang.org/x/crypto/ssh package supports marshaling ed25519.PrivateKey to the OpenSSH format using the MarshalPrivateKey function [1][2][3]. Regarding the ed25519.PrivateKey value pointer: The MarshalPrivateKey function accepts an argument of type crypto.PrivateKey, which is an interface [1][2]. While the standard representation of an Ed25519 private key in Go's crypto/ed25519 package is the type ed25519.PrivateKey (which is a byte slice), the implementation of MarshalPrivateKey in golang.org/x/crypto/ssh is designed to handle this type [4][5]. Specifically, the implementation internally handles Ed25519 keys by checking for the ed25519.PrivateKey type [5]. Some versions of the code explicitly handle pointers to these keys—for instance, by dereferencing them if provided as *ed25519.PrivateKey—to ensure compatibility with the required structure [5]. You can pass an ed25519.PrivateKey (the underlying byte slice) directly to MarshalPrivateKey, and the library will successfully marshal it into the OpenSSH format [4][5]. In summary: - MarshalPrivateKey supports ed25519.PrivateKey [4][5]. - You do not need to manually wrap the key in a pointer if you already have the standard ed25519.PrivateKey type, but if you do provide a pointer (*ed25519.PrivateKey), the library's marshaling logic is equipped to handle it correctly [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- locate auth.go and related files ---\n'
fd -t f 'auth\.go' .
printf '\n--- outline internal/ssh/auth.go ---\n'
ast-grep outline internal/ssh/auth.go --view expanded || true
printf '\n--- relevant auth.go sections ---\n'
sed -n '1,180p' internal/ssh/auth.go

printf '\n--- search NewSSHKeyAuth and decryptPrivateKeyPEM usages ---\n'
rg -n "NewSSHKeyAuth|decryptPrivateKeyPEM|PrivateKeyPEM|newSystemGitEnv" internal .

Repository: ConfigButler/gitops-reverser

Length of output: 257


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- locate auth.go and related files ---'
fd -t f 'auth\.go' .

echo
echo '--- outline internal/ssh/auth.go ---'
ast-grep outline internal/ssh/auth.go --view expanded || true

echo
echo '--- relevant auth.go sections ---'
sed -n '1,220p' internal/ssh/auth.go

echo
echo '--- search NewSSHKeyAuth and decryptPrivateKeyPEM usages ---'
rg -n "NewSSHKeyAuth|decryptPrivateKeyPEM|PrivateKeyPEM|newSystemGitEnv" internal .

Repository: ConfigButler/gitops-reverser

Length of output: 14026


🌐 Web query:

golang.org/x/crypto v0.54.0 ssh keys.go MarshalPrivateKey ed25519.PrivateKey dsa PrivateKey

💡 Result:

In the golang.org/x/crypto/ssh package (version v0.54.0), the MarshalPrivateKey function is used to serialize private keys into the OpenSSH format [1][2][3]. This function supports various key types, including ed25519.PrivateKey [1][4]. Regarding DSA (*dsa.PrivateKey): DSA is considered an obsolete and insecure algorithm [5]. In modern versions of the golang.org/x/crypto/ssh package, support for DSA has been significantly restricted [6][5]. While ParseDSAPrivateKey remains available for legacy purposes (marked as deprecated), general support for DSA keys in the package has been phased out or made opt-in to improve security [6][5]. Specifically, ParseRawPrivateKey and ParseRawPrivateKeyWithPassphrase no longer support DSA keys directly for automated parsing [6]. To use DSA, a developer must now explicitly use ParseDSAPrivateKey and then use NewSignerFromKey [6]. Furthermore, DSA is no longer included in default algorithm sets, meaning its use must be explicitly configured on both client and server sides if required [6]. In summary, while Ed25519 is fully supported for marshalling, DSA support is deprecated and requires explicit handling due to its security status [6][5].

Citations:


Move unencrypted key re-serialization out of every SSH auth construction.

decryptPrivateKeyPEM runs for all remote providers and calls gossh.MarshalPrivateKey, which drops *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, and rejects unsupported key types such as *dsa.PrivateKey. Since only the ADO system-git fallback needs raw unencrypted PEM bytes, defer this failure to newSystemGitEnv/system-git so go-git-based SSH providers keep working.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ssh/auth.go` around lines 68 - 71, Update the SSH authentication
construction around decryptPrivateKeyPEM so it does not re-serialize decrypted
keys through gossh.MarshalPrivateKey for every provider. Preserve the parsed
key/signer for go-git-based providers, and perform raw unencrypted PEM
serialization only in newSystemGitEnv or the system-git fallback that requires
it, allowing supported and unsupported key types to continue through other SSH
providers.

…tler#288)

go-git v5 strips MultiACK/MultiACKDetailed from its capability
advertisement. ADO rejects any upload-pack request that omits
multi_ack with HTTP 400 (TF401041: "Clients must support multi-ack.").

This change adds a per-URL system-git fallback: any remote whose URL
contains dev.azure.com, visualstudio.com, or ssh.dev.azure.com is
routed through exec.Command("git", ...) instead of go-git. All other
providers continue to use the existing go-git path unchanged.

Three entry points now delegate to system git for ADO URLs:
- SmartFetch → systemGitSmartFetch (git ls-remote + git fetch)
- PushAtomic → systemGitPushAtomic (git ls-remote + git push --force-with-lease)
- CheckRepo  → systemGitCheckRepo  (git ls-remote --symref)

Credentials are never passed via argv:
- BasicAuth: GIT_CONFIG_GLOBAL with http.extraHeader = Authorization: Basic <base64>
- TokenAuth: GIT_CONFIG_GLOBAL with http.extraHeader = Authorization: Bearer <token>
- SSH: GIT_SSH_COMMAND with -i <keyfile> and optional known_hosts

Dockerfile: adds a git-bundle Alpine stage that collects git and all
its musl shared-library dependencies via ldd, then copies them into
the distroless final image. The distroless/static:debug base already
includes /bin/sh (busybox), which is required to execute the
GIT_ASKPASS script.

Proper fix: go-git PR #1204 (full multi_ack implementation) is targeted
for v6.0.0, which is still in alpha as of 2026-07-28. Drop this
fallback and upgrade to go-git v6 once a stable release lands.
@consooo
consooo force-pushed the fix/288-ado-system-git-fallback branch from 59ed890 to 2c5d321 Compare July 30, 2026 10:22
@sunib

sunib commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for the quick reply @sunib. Yeah, I was quite surprised as well. I tested with a private github repo and everything was fine. Switching to ADO, I encountered these weird 400s. This seems to be a well known annoying shortcoming, I would put the blame squarely on ADO. It's been there for 6 years! go-git/go-git#64 fluxcd/source-controller#104 (comment)

I'm going to be honest: I dislike calling git as external tool: go-git is so elegent as in that it's not going to require specials in the Dockerfile for example. Did you consider to live on the edge and try go-git version 6? I have so many tests that I would be willing to take the risk on working with a beta release of their side: especially if that would give a more clean approach.

I agree, this is not ideal. Most projects add a workaround either using system git or switch to or add git2go lib. I also considered just using go-git v6 or switching to git2go. But both require quite a bit of refactoring and go-git v6 has been in alpha for a while. There is no telling when it will be stable and it will lead to lots of work adapting to API changes. It might not be too bad if we simplified the git flow but with the current status, I think this would be quite the hassle.

Perhaps we can also add a flag to bypass the advacned pushing: and just do a more simple out of the box push. One would hope that it then works as well: it simply can't be true that ADO doesnt work with go-git v5 can it?

Even a git fetch won't work I'm afraid.

System git is the least painful way imho. Its only used in the ADO path, not for other repos. I noted that this is not the correct fix. The correct fix is to lift to v6 and this is only a temporary fix until it is released. Adding these binaries is not nice. But I think its preferable to doing a migration to go-git v6 and then keeping up with the changes until a stable version is released. This would be much more work.

Thanks for your quick replys and the attention/time that you are putting in this (also for the previous discussion on watches!). I'm running some tests in the background and actually found some interesting material on this, I'm working on a seperate branch to test if v6 is easy to do: if it is then I will take the guess to do some potenial rework on new v6 releases. I've just taken a closer look at their API changes and they are actually pretty carefull. -> and that version just fully suppors ADO out of the box.

Normally I would just merge your PR btw, it's great work: but taking these extra Dockerfile deps isn't funny. The image size is 4x bigger, and the security implications are also annoying.

Do you have problems with that route? You clearly have already spend time on this: and I might be overseeing things. I will create a PR in a minute, that will also include the more elaborate explanation.

@consooo

consooo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for your quick replys and the attention/time that you are putting in this (also for the previous discussion on watches!). I'm running some tests in the background and actually found some interesting material on this, I'm working on a seperate branch to test if v6 is easy to do: if it is then I will take the guess to do some potenial rework on new v6 releases. I've just taken a closer look at their API changes and they are actually pretty carefull. -> and that version just fully suppors ADO out of the box.

Normally I would just merge your PR btw, it's great work: but taking these extra Dockerfile deps isn't funny. The image size is 4x bigger, and the security implications are also annoying.

Do you have problems with that route? You clearly have already spend time on this: and I might be overseeing things. I will create a PR in a minute, that will also include the more elaborate explanation.

Sure, you know your repo much better then I do so you can better estimate the impact switching to go-git v6 has. I'd be fine with this too. You're right, that these additional binaries aren't nice, so if you can get go-git v6 to work, it'd be great. I just didn't want to be the one opening that bottle 😄

sunib added a commit that referenced this pull request Jul 30, 2026
…address the review

Four review findings, all real:

  - PrepareBranch pinned the signing policy only on repositories it CREATED.
    Worker clones live on a volume across restarts, and a repository made
    before the pin existed is the common case on upgrade -- either would hit
    go-git v6's "cannot auto-sign commit" the first time an ambient
    commit.gpgSign is true. The pin now covers the reuse path too, which is
    where it matters most.
  - getPushSession indexed remote.Config().URLs[0] unguarded. go-git rejects
    a URL-less remote in its own validation, but a hand-edited .git/config
    can still present one, and indexing it panics rather than fails.
  - The pinned-known_hosts subtest could not tell a pin hit from the default
    fallback, because the default set also carries the RSA algorithms. It now
    asserts the list is narrower than the default and excludes ed25519, which
    only the pin can produce.
  - docs/INDEX.md still said "decision needed" after the design record moved
    to decided-and-built, and undercounted its own list by one.

Separately, and found by porting #292's Azure DevOps examples: the credential
those examples document did not work. ADO sends a PAT as HTTP basic auth with
the token as the password and ignores the username, so its documented Secret
carries an empty username -- and firstSecretValue treats an empty value as an
absent key, so the basic-auth branch never fired and the Secret was refused
with "does not contain valid authentication data". Pre-existing on v5 too.
The password is what carries the credential, so it is what we branch on now. A
username with no password stays an error, because that one is a real mistake.

Verified against a real Azure DevOps repository, which is what the new opt-in
tests are for. Both skip themselves without a credential, so CI is unchanged:

  - internal/git/ado_live_test.go walks the branch-resolution contract against
    the real remote in the order the code implements it -- empty repository
    resolves to nothing, an absent target falls back to the default, a present
    target wins while the default is still fetched as a safety net -- and ends
    on the negotiating fetch, the one request go-git v5 cannot make.
  - test/e2e/ado_e2e_test.go proves the operator mirrors a live ConfigMap into
    a real ADO repository. It reads the result back with canonical git rather
    than our own library, so the assertion does not depend on the code under
    test.

docs/azure-devops-getting-started.md is written from that e2e recipe, and says
plainly why the connectivity check can pass on a release where every fetch
fails: the advertisement is a different request that never needed multi_ack.

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sunib added a commit that referenced this pull request Jul 30, 2026
* feat(git): go-git v6, so Azure DevOps repositories can be fetched at all

Azure DevOps rejects any protocol-v0 upload-pack request whose capability
list omits multi_ack, with HTTP 400 "TF401041: Clients must support
multi-ack." go-git v5 keeps MultiACK and MultiACKDetailed in
transport.UnsupportedCapabilities and deletes them from the server's
advertisement as it parses, so the capability is never requested and every
fetch against ADO fails (#288).

v6 implements the capability (go-git#1204), and upstream then deleted their
own ADO workaround example saying it "works out of the box". This takes
v6.0.0-alpha.5 -- the latest tag, and identical to upstream main -- rather
than PR #292's fallback to a bundled system git binary, which measured at
+723 MB of image and left the CRITICAL image-scan gate blind to git,
OpenSSH and OpenSSL because they arrive as loose files with no package
database. The reasoning, the measurements and the four options are in
docs/design/azure-devops-multi-ack.md.

The blast radius of the ADO problem is one call, repo.Fetch. CheckRepo and
listRemoteRefs read only the ref advertisement, and PushAtomic speaks
receive-pack, which has no multi_ack at all. The tests assert exactly that,
and they passed on v5.

Red-first, and it needs no Azure DevOps tenant: canonical git's own
upload-pack advertises multi_ack, so git-http-backend behind a proxy that
enforces ADO's rule is a faithful simulator for both halves -- the proxy
reproduces the rejected request, the real backend reproduces the multi-ACK
response v5 also cannot parse. The v2 opt-in header is stripped so a
v2-capable client cannot sidestep the capability under test.
TestADO_SmartFetch_RequiresMultiAck fails on v5 with ADO's exact 400 and
passes on v6.

The API migration:

  - transport.AuthMethod is gone; auth is functional options. A credential
    now travels as []gitclient.Option, and git.Credential keeps the
    concrete value alongside so the Secret-key-to-auth-field mapping stays
    assertable -- the options are closures and cannot be inspected.
  - transport.NewEndpoint + client.NewClient + NewReceivePackSession
    become transport.ParseURL + gitclient.New(opts).Handshake.
  - AdvertisedReferences becomes GetRemoteRefs, returning a slice.
  - ReceivePack becomes Session.Push.

The atomic push keeps its guarantee unchanged: one session serves both the
advertisement and the push, and PushRequest.Commands takes the same
*packp.Command, so the server-side Old/New compare-and-swap is verbatim.
v6 negotiates report-status itself and returns a rejected command as the
error from Push, so the separate status inspection collapses into one
check, and Atomic is now a first-class field.

Two settings v6 reads from the environment that v5 ignored, both failing
closed, and neither visible to a unit test:

  - commit.gpgSign, merged across system, global and local scope, is
    consulted whenever CommitOptions.Signer is nil, and refuses the commit
    when set with no signer registered. Any host or image with it set
    would break every commit we make. PinExplicitSigningPolicy writes the
    local value false at init: our signing policy comes from the
    GitProvider, not from ambient config.
  - HostKeyAlgorithms is derived by reading ~/.ssh/known_hosts and
    /etc/ssh/ssh_known_hosts whenever ClientConfig returns it empty, even
    when a HostKeyCallback was supplied, and hard-fails when neither file
    exists. The controller image is distroless with neither, so every SSH
    remote would have failed in production regardless of the credential.
    ssh.KeyAuth now always populates the list, from the pinned known_hosts
    when there is one and a modern default set otherwise. Found by the e2e
    suite against a real Gitea; no unit test can reach it, because the
    fallback lives in the transport's connect rather than in ClientConfig.

TestBranchWorker_ConcurrentOperations moves to a real git server. v5's
file:// transport spawned the real git-receive-pack; v6 runs go-git's
in-process one, whose updateReferences never compares cmd.Old and just
sets the reference, so over file:// every racing push wins and the test
was passing vacuously with 2 commits instead of 4. Any future test of the
compare-and-swap must avoid file://.

Closes #288
Refs #292

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(git): accept the Secret Azure DevOps tells people to create, and address the review

Four review findings, all real:

  - PrepareBranch pinned the signing policy only on repositories it CREATED.
    Worker clones live on a volume across restarts, and a repository made
    before the pin existed is the common case on upgrade -- either would hit
    go-git v6's "cannot auto-sign commit" the first time an ambient
    commit.gpgSign is true. The pin now covers the reuse path too, which is
    where it matters most.
  - getPushSession indexed remote.Config().URLs[0] unguarded. go-git rejects
    a URL-less remote in its own validation, but a hand-edited .git/config
    can still present one, and indexing it panics rather than fails.
  - The pinned-known_hosts subtest could not tell a pin hit from the default
    fallback, because the default set also carries the RSA algorithms. It now
    asserts the list is narrower than the default and excludes ed25519, which
    only the pin can produce.
  - docs/INDEX.md still said "decision needed" after the design record moved
    to decided-and-built, and undercounted its own list by one.

Separately, and found by porting #292's Azure DevOps examples: the credential
those examples document did not work. ADO sends a PAT as HTTP basic auth with
the token as the password and ignores the username, so its documented Secret
carries an empty username -- and firstSecretValue treats an empty value as an
absent key, so the basic-auth branch never fired and the Secret was refused
with "does not contain valid authentication data". Pre-existing on v5 too.
The password is what carries the credential, so it is what we branch on now. A
username with no password stays an error, because that one is a real mistake.

Verified against a real Azure DevOps repository, which is what the new opt-in
tests are for. Both skip themselves without a credential, so CI is unchanged:

  - internal/git/ado_live_test.go walks the branch-resolution contract against
    the real remote in the order the code implements it -- empty repository
    resolves to nothing, an absent target falls back to the default, a present
    target wins while the default is still fetched as a safety net -- and ends
    on the negotiating fetch, the one request go-git v5 cannot make.
  - test/e2e/ado_e2e_test.go proves the operator mirrors a live ConfigMap into
    a real ADO repository. It reads the result back with canonical git rather
    than our own library, so the assertion does not depend on the code under
    test.

docs/azure-devops-getting-started.md is written from that e2e recipe, and says
plainly why the connectivity check can pass on a release where every fetch
fails: the advertisement is a different request that never needed multi_ack.

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(facts): what Azure DevOps actually rejects, measured one request at a time

I probed ADO with a bare `want <sha>` carrying no capability list, got HTTP
200, and concluded that the six-year-old bug reports were wrong. They are not.
That shape is the one row ADO accepts, and no real client sends it. Running
go-git v5.19.1 against the same repository reproduced the reported failure
immediately -- and not on a fetch, on the CLONE:

  CLONE FAILED: unexpected client error: unexpected requesting
  ".../git-upload-pack" status code: 400

The rule, measured one request shape at a time against a real repository:

  want <sha>                                        -> 200
  want <sha> side-band-64k ofs-delta agent=...      -> 400 TF401041
  want <sha> side-band-64k                          -> 400 TF401041
  want <sha> agent=git/2.39.5                       -> 400 TF401041
  want <sha> multi_ack side-band-64k ofs-delta      -> 200
  want <sha> multi_ack_detailed side-band-64k       -> 200

So the trigger is a capability list that omits multi_ack, not the absence of
negotiation, and either capability satisfies it. v5 filters both out of the
advertisement before deciding what to ask for, so every request it sends is
the rejected shape.

The doc keeps the wrong turn rather than quietly correcting it, because the
failure mode is worth naming: probing with a hand-rolled request tests the
request you built, not the behaviour you are attributing to it, and
contradicting a long-standing external bug report needs a reproduction with
the real client rather than a curl that disagrees.

Also records what canonical git advertises (which is why it never notices this
at all, and why git-http-backend is a usable stand-in), that receive-pack has
no multi_ack whatsoever so pushing was never affected on any version, and the
sources behind each claim.

TestADOLive_StillRequiresMultiAck turns the premise into a canary: it asserts
ADO STILL rejects the capability-list-without-multi_ack shape, and fails loudly
with "GOOD NEWS, NOT A BUG" if Microsoft ever fixes it. Verified against the
real server, which answers:

  TF401041: The Git protocol sent is not as expected (Clients must support
  multi-ack.).

The public-fixture idea is dropped -- Azure DevOps no longer allows new public
projects -- so the live tests stay opt-in and PAT-gated, and never run in CI.
E2E_ADO_EMPTY_REPO_URL names a repository that stays empty, which is the only
way to cover the empty-repository contract more than once: the main fixture
seeds itself on first run.

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: Azure DevOps setup gets its own page, like GitHub's

configuration.md had grown a full Azure DevOps walkthrough inline -- the Secret,
the GitProvider, Entra, SSH, and the multi_ack background -- duplicating
azure-devops-getting-started.md a screen below the GitProvider example.
github-setup-guide.md already establishes the pattern: per-provider setup is its
own page, and configuration.md points at it.

So the section goes, and what stays is the single thing that surprises people,
placed where it bites: a note under the credentials-Secret auth-keys table,
which is exactly where a reader learns HTTP basic means username + password and
needs to know Azure DevOps is the exception that carries only a password.

Also wires E2E_ADO_EMPTY_REPO_URL through the getting-started guide, and names
TestADOLive_StillRequiresMultiAck as a canary there, so someone hitting a red
build knows a failure means Microsoft fixed their end rather than that something
broke.

Verified against the real fixtures at dev.azure.com/configbutler/tests: all four
live tests pass, including the empty-repository case against a repository that
stays empty and the canary, which reports

  TF401041: The Git protocol sent is not as expected (Clients must support
  multi-ack.).

and the operator-level e2e spec mirrors a live ConfigMap into the repository
(1 passed, 0 failed).

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bound the canary's request, and two doc statements that contradicted themselves

Three review findings, all valid:

  - The canary had no deadline on either half: context.Background() and
    http.DefaultClient's zero Timeout. An unreachable or mid-response Azure
    DevOps would hang the test rather than reaching the skip its error path
    already intends. Bounded at 30s.
  - "E2E_ADO_EMPTY_REPO_URL must stay empty" reads as an instruction to unset
    the variable, two lines below the command that sets it. It is the
    REPOSITORY it names that must stay empty.
  - The facts page claimed the simulator "rejects any upload-pack POST without
    multi_ack", which contradicted its own measured table two screens above:
    the check matches the multi_ack PREFIX, so multi_ack_detailed satisfies it,
    exactly as ADO does. Verified rather than assumed -- "multi_ack" is a
    substring of "multi_ack_detailed", and the simulator's test is
    bytes.Contains.

The third is the one worth noticing: a page whose entire purpose is to record
what was measured had a summary sentence disagreeing with its own table.

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(azure-devops): PAT-only, and shorter

Review feedback. The guide read as a tour rather than a path a newcomer can
follow, and advertised two credential types nobody has tested against Azure
DevOps.

  - SSH and Entra bearer tokens are no longer presented as setup steps. They
    use the same Secret keys as any other provider and probably work exactly
    as they do for GitHub, but nothing here has exercised them against ADO, so
    they are named once and labelled untested rather than walked through.
  - `kubectl create namespace` is now in the flow. The Secret command failed
    without it.
  - The branch is a `<branch>` placeholder used in both resources, instead of
    a hard-coded `main` the reader had no reason to think was a choice.
  - The PAT step says which organization, to set an expiry, to copy the token
    when shown, and that it inherits its user's repository permissions.
  - security-model.md claimed HTTP basic auth requires `username`, which
    contradicts the Azure PAT contract this branch introduced. It now says the
    password is what selects basic auth, and that ADO omits the username.

Both pages are shorter: 155 -> 125 lines for the guide, and the facts page
loses the essay while keeping the measurements and sources.

One review point is not applied, because the evidence contradicts it: audit
delivery is NOT a prerequisite for the final ConfigMap to produce a commit.
Attribution is what needs audit; writes do not. The chart defaults to
configured-author with attribution disabled, and test/e2e/ado_e2e_test.go sets
up no audit at all yet passes by reading the committed manifest back out of
Azure DevOps. The guide now says this explicitly, since the reviewer is
unlikely to be the last person to assume otherwise.

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the username is optional for every provider, not an Azure DevOps carve-out

The docs insisted on leaving `username` out, which overstates what the code
does and framed a general rule as provider-specific.

Read back: nothing in CredentialFromSecretData mentions Azure DevOps. It
branches on `password`, and passes whatever `username` it found straight
through. So all three shapes work -- `password` alone, `username: ""` with a
password, and a real username with a password -- and only a username WITHOUT a
password is an error.

Measured against a real ADO repository, `CheckRepo` is accepted with the
username set to "", "pat", "anything-at-all", and the account's own name. ADO
genuinely ignores it, so "do not add a username" was advice for a problem that
does not exist.

configuration.md now states the rule where the auth-keys table is, since that
table said HTTP basic needs `username` + `password` and thereby contradicted
it: the password is what selects basic auth, the username is optional, and an
empty value is the same as an absent key to the credential reader. That last
point is the only genuinely surprising part, and it is a property of
firstSecretValue rather than of Azure DevOps.

The getting-started guide drops the warning and says the username is optional
and ignored, which is both shorter and true.

A test now covers the case that had none: a supplied username must survive
into the credential. The three ADO-shaped cases were already covered; the
ordinary one was not, which is how the docs drifted from the code unnoticed.

Refs #288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sunib

sunib commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

It looks like that go-git 6 upgrade is holding up pretty nicely (#297): so I'm closing this. Thanks for opening this and your work, appreciated!

@sunib sunib closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] fetch fails with HTTP 400 against Azure DevOps repositories (multi_ack capability)

2 participants