Skip to content

Fix/checkout robustness - #6

Merged
ashish1099 merged 21 commits into
masterfrom
fix/checkout-robustness
Sep 4, 2026
Merged

Fix/checkout robustness#6
ashish1099 merged 21 commits into
masterfrom
fix/checkout-robustness

Conversation

@Rishikesh01

Copy link
Copy Markdown
Collaborator

No description provided.

Rishikesh01 and others added 3 commits September 1, 2026 10:00
Three standard-mode (non-openvox) failures hit on a repo's first sync:

- An empty upstream (no commits yet) aborted the whole sync with
  "remote repository is empty". It is now a benign no-op success that
  keeps polling until commits are pushed.
- When `checkout` is unset the working tree was never populated; it now
  defaults to the remote HEAD (default) branch, skipping gracefully if
  that branch isn't mirrored locally.
- An incomplete local object store (a ref resolves but its tree/blobs
  are missing, e.g. an interrupted prior fetch on a persistent volume)
  made checkout fail with "object not found". go-git treats the
  present-but-partial commit as a "have" and won't repair it in place,
  so SyncRepo now wipes the local mirror and rebuilds once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fn7LgEVQzxhwZq6Ff3BnK
- Recover the first-sync case where the shared cache holds refs but has
  a dangling HEAD (the upstream default branch isn't "master"): fall
  back to init+alternates instead of failing every per-branch clone with
  "reference not found".
- Don't take the up-to-date fast path when a ref resolves but its objects
  are missing locally; verify object presence so the branch/tag is
  refetched instead of failing checkout with "object not found".
- Treat "object not found" as a recoverable error so the existing
  recreate-and-retry path self-heals it.
- Treat an empty upstream as a benign no-op success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fn7LgEVQzxhwZq6Ff3BnK
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fn7LgEVQzxhwZq6Ff3BnK
@Rishikesh01
Rishikesh01 marked this pull request as draft September 1, 2026 05:06
Rishikesh01 and others added 5 commits September 1, 2026 12:21
Bump the go directive in go.mod, the Dockerfile builder image, and the
docs to Go 1.27. CI reads the version from go.mod (go-version-file), so
the workflows need no change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fn7LgEVQzxhwZq6Ff3BnK
Surfaced once golangci-lint could parse Go 1.27:

- errcheck: check previously-ignored error returns in the new test
  helpers (SetReference, CreateRemote, WriteFile, Add, Worktree).
- goconst: use the existing MainBranch constant instead of repeated
  "main" string literals.
- revive: drop the unused receiver on recordSyncSuccess.

No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fn7LgEVQzxhwZq6Ff3BnK
golangci-lint v2.12.2 cannot decode Go 1.27 export data ("export data
version 4 is greater than maximum supported version 2"), failing the
lint job after the toolchain bump. v2.13.2 is built with go1.27.0.
Updated in both the GitHub and Gitea workflows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fn7LgEVQzxhwZq6Ff3BnK
…anch

Branch selection could only be expressed as patterns, so a caller that
does not know the default branch name up front had no way to ask for it.
The choice was to guess ("main", "master") and fail when the guess was
wrong, or widen to "*" and mirror every branch in the repo.

gfetch already reads the default branch from the remote's advertised HEAD
symref; it just could not act on it. default_branch_only makes that
available: the branch patterns are ignored and the only branch synced is
the one HEAD points at, whatever it is called.

Branches and tags become optional when it is set, since the option
selects the branch by itself. It is rejected together with openvox, which
exists to sync every matched branch.

extractRemoteRefState now resolves HEAD in its own pass before matching,
because a server may advertise HEAD after the branch refs and the
selection depends on knowing it.
default_branch_only takes the branch from the remote HEAD, so branches
and checkout do not merely duplicate it, they contradict it. Silently
ignoring them leaves an operator staring at config that has no effect.
Reject all three combinations instead: branches, checkout, and openvox,
which exists to sync every matched branch.

Tags stay allowed. They are a separate axis and syncing the default
branch plus a set of tags is a coherent thing to ask for.

Inherited branch patterns are skipped rather than rejected. A defaults
block naming branches would otherwise fail every default_branch_only
repo under it, for config the operator never wrote that way. The
default_branch_only merge moves ahead of the pattern merges so that
check can see it.
Rishikesh01 and others added 13 commits September 2, 2026 15:30
Three defects found in review of the default_branch_only work.

default_branch_only leaves repo.Branches empty by design, so matching
obsolete branches against it marked every local branch obsolete,
including the one just synced. With prune enabled that branch was then
deleted while the sync still reported success. The guard in pruneBranches
could not prevent it: it keys on repo.Checkout, which validation forbids
in exactly this mode. Match against the branches actually selected
instead.

The implicit checkout path returned a warning without setting result.Err,
so for any repo with no checkout configured - which is every repo the API
writes - result.Err stayed nil and SyncRepo's object-repair retry could
never fire. A partial object store reported healthy. Only skip silently
when the ref really is absent locally; if it is present the checkout
failed for a real reason. The old warning text was also wrong: the ref
was present, its objects were not.

The inherited-branches test asserted against a Config it built itself and
called Validate, which never runs applyDefaults - so it passed with the
guard deleted and proved nothing. It now goes through Load, and fails if
the guard is removed.

Also hoist two test constants that broke goconst under the pinned linter.
Two ways a repo could get permanently stuck, both found on live syncs.

go-git cannot fetch objects from Azure DevOps. The fetch returns nil, the
refs are written, and the object store is left completely empty - no pack,
no loose objects, no error. Checkout then fails with "object not found"
and every later poll repeats it. Reproduced outside the cluster against
ebillet's repo with all four shapes go-git offers: narrow refspec,
wildcard refspec, and both PlainClone variants. All wrote refs and zero
objects, while the git binary fetched 645. v5.19.2 is the latest v5, so
there is nothing to upgrade to. Shell out to git for those hosts.

Note this also means the object-repair retry could never have helped such
a repo: there is nothing partial to rebuild, so wiping and refetching just
produces another empty store.

Separately, a truncated .git/index made checkout fail forever with
"malformed index signature file" on gitea-hosted repos. go-git reads the
index before touching the worktree, so once it is corrupt nothing can
proceed - and the gfetch container runs without a memory limit, so being
killed mid-write is expected rather than exotic. The index is pure cache
rebuildable from HEAD, so delete it and retry the checkout instead of
re-cloning.

The runtime image gains git and openssh-client, which the exec path needs.
The Azure DevOps workaround only covered the branch fetch, so any other
call that pulls objects still went through go-git and would still come
back empty. Tags are the obvious one - they carry objects like any other
ref - and the staleness checks fetch commits to read their dates.

execFetch is now generic over flags and refspecs, with execFetchBranch and
execFetchTags as thin wrappers, and the tag and staleness paths pick the
git binary on the same host check.

The openvox resolver's staleness fetch passes a nil config on purpose: it
fetches into the shared resolver repo at cachePath rather than
repo.LocalPath, so pointing the exec path at it would run git in the wrong
directory. openvox keeps using go-git there.
ensureCloned stat'd repo.LocalPath, which succeeds for any directory that
happens to exist - one the shared-PVC init container created, or one a
failed earlier sync left behind. PlainOpen then ran against a path holding
no repository, and because that branch returns early it also skipped
CreateRemote, so the init path that configures "origin" never ran.

Stat .git instead, so a directory without a repository in it takes the
init path as intended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CthHiNTaGktfu1N1coRDb6
Root cause of the empty object stores, and it is not host-specific after
all. go-git ships with MultiACK, MultiACKDetailed and ThinPack all
declared unsupported, so it never advertises them. Azure DevOps answers a
client offering no multi_ack with an empty pack rather than an error: the
fetch returns nil, the refs are written, and no objects arrive. Checkout
then fails with "object not found" and nothing self-heals, because the
refs already point at the right commits.

Measured against ebillet's repo: stock capabilities produced zero objects
across all eleven call shapes we tried, while re-enabling multi_ack
produced the same 172K pack the git binary sends.

This replaces the exec workaround from the previous commits, which is now
deleted along with the host list, the tag and staleness plumbing it
needed, and the git/openssh-client packages in the runtime image. One
assignment covers every remote instead of special-casing four hostnames.

ThinPack stays unsupported: unlike the other two that reflects a real gap
in go-git's pack handling rather than a negotiation preference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CthHiNTaGktfu1N1coRDb6
The option is named for what it does, so tags belong with branches and
checkout in the rejected set rather than being a separate axis you can
still opt into. Reverses the earlier call to allow them.

Inherited tags are skipped rather than rejected, matching the treatment of
inherited branches: a defaults block naming tags must not fail every
default_branch_only repo underneath it for config the operator never
wrote.

This changes nothing for the configs the API writes - they already set no
tags - but it stops a repo half-using the option and quietly syncing tags
nobody asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CthHiNTaGktfu1N1coRDb6
The multi_ack assignment was an init() in its own file, which mutated a
go-git global as a side effect of importing the package. Move it into
Syncer.New so it happens when a syncer is actually constructed; both the
daemon and the CLI go through there.

Record the upgrade constraint in AGENTS.md under "Known future work":
go-git v6 removes transport.UnsupportedCapabilities, so this will not
compile against it, and the fetch, clone and capability plumbing all need
reworking rather than a version bump. The Azure DevOps behaviour has to be
re-verified against whatever replaces it.

Kept out of the code as a literal TODO because godox rejects those; the
repo already tracks this kind of thing in AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CthHiNTaGktfu1N1coRDb6
writeKnownHostsFile was split out of buildKnownHostsAuth so the temp file
could be shared with the git-exec fetch path. That path is gone - the
Azure DevOps empty-pack problem is handled by the multi_ack capabilities
instead - so the helper has had exactly one caller since, while its doc
comment still advertised "the exec path, which hands the path to ssh as
UserKnownHostsFile".

Inlining it restores the defer os.Remove and drops the hand-threaded
cleanup func, including the two error returns that handed back a
do-nothing func(){}. It also puts buildKnownHostsAuth's own doc comment
back on buildKnownHostsAuth: the split had stranded it above
writeKnownHostsFile, leaving that function with two stacked comments and
the exported behaviour undocumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1xm6zw8ancQ9bcLwA1xp9
Four cleanups to the standard sync path, no behaviour change:

recordSyncSuccess was a copy of SyncRepo's own success tail - observe
duration, logSyncSuccess, SyncSuccessTotal, LastSuccessTimestamp. The
empty-remote path always has a nil result.Err, so it now falls through to
that shared tail and the helper is gone.

The object-repair retry moves into repairIncompleteObjectStore, which
returns early when the wipe fails instead of hanging the retry off an
else. SyncRepo no longer carries the repair detail inline.

handleCheckout gets the implicit case resolved up front. It used to check
out the default branch unconditionally, then classify the failure by
re-resolving the ref a third time to decide whether it was benign - so a
repo whose patterns exclude HEAD paid a doomed checkout on every sync.
Resolving the ref first drops handleImplicitCheckoutErr and the implicit
flag entirely, along with an "if implicit" nested under a condition that
was unconditionally true whenever implicit was set.

findStaleBranches keeps taking repo.Branches rather than the patterns
synthesized for the obsolete check. Under default_branch_only those are
the branch just synced, and stale pruning would delete it for being old -
the very deletion the obsolete path was fixed to prevent. Noted in a
comment, since the asymmetry looks like an oversight otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1xm6zw8ancQ9bcLwA1xp9
The .git/index repair was added to checkoutRef, but openvox calls
checkoutRefContext directly for both branches and tags, so it never got
the repair. That is backwards: openvox keeps N worktrees per repo and
checks them out from concurrent workers, so it is the mode most exposed
to a container killed mid-index-write - and isRecoverableOpenVoxRepoError
does not list the malformed-index error either, leaving such a branch dir
permanently fatal with no retry.

Move the detect-remove-retry into checkoutRefContext, which is the one
function that decodes the index, and add checkoutRefOnce for the raw
attempt. checkoutRef stays as the background-context wrapper. Every
caller now gets the repair, including any future one.

The test asserts the precondition against checkoutRefOnce and the repair
against checkoutRefContext, so it pins the behaviour at the layer openvox
actually uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1xm6zw8ancQ9bcLwA1xp9
Error identification stopped relying on hand-written message text. Every
match now sources its string from the go-git error value, so the two
cannot drift: plumbing.ErrObjectNotFound, dotgit.ErrEmptyRefFile,
index.ErrMalformedSignature, transport.ErrEmptyRemoteRepository and
plumbing.ErrReferenceNotFound. The "couldn't find remote ref" match
became errors.Is(err, git.NoMatchingRefSpecError{}), which that type
supports via its Is method, so no string is involved at all.

isRecoverableOpenVoxRepoError also gained the errors.Is arm for
ErrEmptyRefFile, which it never had, and now calls isObjectNotFoundErr
rather than keeping a second, weaker copy of the same test. The text
fallbacks stay: only ErrObjectNotFound is confirmed to survive wrapping,
and dotgit returns ErrEmptyRefFile bare from readReferenceFrom.

objectPresentLocally is gone. It guarded the openvox fast path against a
ref that resolves while its objects are missing, but it only checked the
tip object - and decoding a commit succeeds while its tree is missing,
which is exactly the shape an interrupted fetch leaves behind. Verified:
with the tree object deleted it still reported present, so the fetch was
skipped on the one case it existed for, while charging every healthy ref
an object decode per poll (and, through alternates, a rebuilt DotGit plus
a decode of every pack index in the shared cache). That state produces
"object not found" at checkout, which isRecoverableOpenVoxRepoError
already classifies, so the ref dir is recreated and re-synced either way.
The cost now lands on the broken ref instead of all the healthy ones.

isBranchUpToDateLocal and isTagUpToDateLocal were identical apart from
the ref constructor, so they fold into isRefUpToDateLocal. The tests
cover both predicate arms - wrapped sentinel and message text - with the
expected text taken from the sentinels, and TestIsRefUpToDateLocal pins
the hash-only contract that replaces the probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1xm6zw8ancQ9bcLwA1xp9
TestCheckoutRef_RepairsCorruptIndex hand-rolled init + worktree + write +
add + commit, which is what initTestRepoWithCommitAtPath already does -
the helper takes a path precisely so the caller can poke at .git
afterwards. It also invented its own author instead of the
DefaultTestName/DefaultTestEmail constants every other test uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1xm6zw8ancQ9bcLwA1xp9
The option was added without documentation in either the README or the
configuration reference the README points to.

The reference gains a field-table row, a Default Branch Only section, and
two validation rules; the branches/tags requirement is relaxed to note it
does not apply in this mode. The section covers what the option is for,
that HEAD is re-resolved on every sync so a changed upstream default is
followed, the four combinations that are rejected and why, the
defaults-inheritance carve-out, and how prune and prune_stale behave.

The README gains a feature bullet, the option in the defaults example, a
repo using it, and a note that it replaces branches/tags rather than
supplementing them.

Behaviour was checked against the binary rather than read off the
validation code: all four rejections produce the documented errors, a
config with neither branches nor tags validates, and gfetch cat confirms
a default_branch_only repo resolves to empty branches/tags while a
sibling repo inherits from defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1xm6zw8ancQ9bcLwA1xp9

@VILJkid VILJkid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is noice!
This is tested against Azure DevOps mechanism.
Let's test this against other Git hosting providers as well.

@ashish1099
ashish1099 marked this pull request as ready for review September 4, 2026 07:48
@ashish1099
ashish1099 merged commit 64f9c66 into master Sep 4, 2026
13 checks passed
@ashish1099
ashish1099 deleted the fix/checkout-robustness branch September 4, 2026 07:49
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.

3 participants