Skip to content

codingame_tools.contribution_manager.manager

manager

CgContributionManager: builds a contribution working directory from an existing server-side contribution (import_), pushes a working directory's content back to the server (push), and reconciles local/server drift (rebase, fetch, and the merge_start/merge_continue/ merge_abort state machine)--backed by a real git repository whose working tree is data/.

Deliberately named push, not commit: this class already has a real, distinct git-level "commit" concept (CgGitRepo.commit_worktree, a plain local commit onto main, no network involved)--calling this method commit() too (as an earlier version of this API did) invited exactly the confusion git-literate users would expect: does it commit locally, or send data to the server? push, matching git push's own "send my local state to the authoritative remote" meaning, does not.

Three branches (see codingame_tools.contribution_manager.layout for the exact names):

  • main: the user's own line--data/ is always main's checkout. Commits here are optional/ user-initiated for the user's own benefit, except a few points where this class also commits automatically (a successful push(), a rebase() fast-forward, merge_discard_local)--see each method's docstring.
  • server: mirrors known server state. Every commit carries git trailers (contribution ID, version, cover binary ID/hash--see contribution_commit_data.CgContributionCommitMetadata) and a server.<version> tag. Its tip is always "the current remote"; git merge-base main server is always "the last point main synced with the server"--no separate last-committed/ remote cache needed, it falls out of branch topology for free.
  • version-data: an orphan branch (unrelated tree history), one commit per server version, holding just contribution-version-data.json (see contribution_commit_data)--the complete redacted CgContribution, kept in full rather than a narrower schema so nothing here needs to change if some future need for another field shows up.

server/version-data are never checked out--every write to them goes through git_repo.CgGitRepo's plumbing (a scratch index, or a single-blob tree for version-data), never touching HEAD, the real index, or anything under data/. This is deliberate: data/ must always and only ever reflect main's real content.

The git-dir itself (objects/refs/HEAD/index/config) lives in one of two places, decided once at create()/import_() time (recorded in .meta/contribution-meta.json, see CgContributionMeta.git_repo) and never re-derived on subsequent commands--so a git project appearing around this directory later can't move a repo that already exists at a fixed spot:

  • External, at <contribution_dir>/.meta/.contribution-git/ with data/ as its work tree, via --git-dir/--work-tree decoupling (see git_repo). Chosen when the working directory is created inside an existing git project. Nothing under <contribution_dir> carries a .git marker, so that outer project's own embedded-repository detection is never tripped.
  • Embedded, at data/.git--data/ as a perfectly ordinary git working directory, drivable with plain git commands. Chosen when nothing was already tracking this location, where there is no outer project for a .git marker to confuse.

.meta/ is not part of that choice: it is always <contribution_dir>/.meta, a sibling of data/, in both layouts. data/ holds user state and only user state.

The portability contract

contribution.json + data/ are the exportable state of a contribution. Copy just those two to another machine--or sync them through an outer git repo, or a backup, or a zip file--run repair(), and you get a consistent working directory. Everything else is reconstructible from them plus the server.

This is the principle that decides where any given piece of state lives, and it cuts sharply:

  • contribution.json and data/ may contain only facts true of the contribution wherever it is. They travel.
  • .meta/ holds facts true of this checkout on this machine. It does not travel, is gitignored, and is always rebuildable.

The git-dir location is the second kind, which is why it lives in .meta/contribution-meta.json and not in the identity manifest. Two checkouts of the same contribution can legitimately disagree about it: exported from a standalone directory (data/.git) into a colleague's monorepo, the copy must come up external (.meta/.contribution-git), because an embedded .git would turn their project's own tracking inside out. A layout recorded in contribution.json would travel with the export and be wrong on arrival--which is exactly what versions through 1.0.x did.

The same contract is why _resolve_git_dir can find an existing repository on disk rather than depending on the record: a freshly exported directory has no .meta/ at all, and must still work.

SOLUTION_FILE_STEM module-attribute

SOLUTION_FILE_STEM = 'solution'

Stem of the one real, editable/submittable solution file, which lives in data/.

STARTER_STUB_GENERATOR module-attribute

STARTER_STUB_GENERATOR = 'read n:int\nwrite answer\n\nINPUT\nn: the single integer read from stdin\n\nOUTPUT\nA single line holding the answer.\n'

Stub generator seeded by create(), deliberately consistent with the seeded test pair.

The stub generator is the one seeded file that isn't inert: CodinGame runs it to produce the starter code every solver of this puzzle begins from, so one that disagrees with the test cases hands them a program that reads the wrong thing. _minimal_valid_contribution_data seeds a single test/validator pair of test_in="1"/test_out="1"--one line, one integer--so this reads exactly that: one int on one line.

write answer emits a placeholder output line, which is the convention for generated stubs; the solver replaces it. The INPUT/OUTPUT blocks become explanatory comments in the generated code.

Syntax reference: https://github.com/CodinGame/codingame-game-engine/blob/master/stubGeneratorSyntax.md Types are int, float, long, word(<length>), string(<length>); loop/loopline/ gameloop handle repeated input. Keep this in step with the seeded test cases if either changes--nothing else checks that they agree.

CgContributionManagerError

Bases: Exception

Raised for contribution-manager-level errors not better represented by a more specific exception (e.g. attempting to push() without a puzzle_type set, or an operation that refuses because a merge is in progress).

CgRebaseStatus

Bases: str, Enum

The outcome of CgContributionManager.rebase().

UP_TO_DATE class-attribute instance-attribute

UP_TO_DATE = 'up_to_date'

server hasn't advanced since main last synced with it (its tip already equals git merge-base main server)--nothing to do, regardless of whether main/the working directory have uncommitted edits.

FAST_FORWARDED class-attribute instance-attribute

FAST_FORWARDED = 'fast_forwarded'

server advanced, but main had no edits since it last synced (main's tip still equals the old merge-base)--fast-forward: main gets a new commit matching server's new tip.

CONFLICT class-attribute instance-attribute

CONFLICT = 'conflict'

Both server and main have diverged since they last synced--nothing was changed. Use cg contribution diff to inspect, and cg contribution merge to resolve.

CgMergeStartStatus

Bases: str, Enum

The outcome of CgContributionManager.merge_start().

STARTED class-attribute instance-attribute

STARTED = 'started'

A real git merge server was attempted. If text_conflicts/binary_conflicts are both empty, it already completed (git commits automatically when there's nothing left unresolved)--merge_in_progress is already False again, no merge_continue() needed or possible. Otherwise, resolve the conflicts and run merge_continue().

ALREADY_IN_PROGRESS class-attribute instance-attribute

ALREADY_IN_PROGRESS = 'already_in_progress'

merge_start() is idempotent--if a merge is already in progress (MERGE_HEAD exists), it leaves it completely untouched rather than erroring or restarting it.

UP_TO_DATE class-attribute instance-attribute

UP_TO_DATE = 'up_to_date'

server's tip already equals git merge-base main server--nothing to merge. Consistent with CgRebaseStatus.UP_TO_DATE.

CgMergeStartResult dataclass

CgMergeStartResult(status, text_conflicts=(), binary_conflicts=())

The outcome of CgContributionManager.merge_start().

text_conflicts class-attribute instance-attribute

text_conflicts = ()

Relative paths where git left <<<<<<<-style conflict markers for manual resolution.

binary_conflicts class-attribute instance-attribute

binary_conflicts = ()

Relative paths where both sides changed differently but the content isn't text--git's own default behavior for a binary conflict is to leave main's (local) version as-is, no markers; pull .git show server:<path> (or cg contribution git show server:<path>) by hand if you want the server's version instead.

CgContributionSyncStatus

Bases: str, Enum

Read-only classification of how main and server currently relate--see CgContributionManager.status(). Distinct from CgRebaseStatus (the outcome of taking an action): this describes the current state without changing anything, and distinguishes LOCAL_AHEAD/SERVER_AHEAD from each other, which CgRebaseStatus doesn't need to (it only cares whether server moved).

NOT_PUSHED class-attribute instance-attribute

NOT_PUSHED = 'not_pushed'

create()d but never successfully push()d--no server branch exists at all yet.

UP_TO_DATE class-attribute instance-attribute

UP_TO_DATE = 'up_to_date'

main and server agree, and there are no uncommitted local edits either.

LOCAL_AHEAD class-attribute instance-attribute

LOCAL_AHEAD = 'local_ahead'

main has commits and/or uncommitted edits beyond the last sync point, but server hasn't moved--a plain push() would succeed with no conflict.

SERVER_AHEAD class-attribute instance-attribute

SERVER_AHEAD = 'server_ahead'

server has moved since the last sync, but main hasn't changed--cg contribution rebase would fast-forward cleanly.

DIVERGED class-attribute instance-attribute

DIVERGED = 'diverged'

Both sides have changed since they last synced--cg contribution rebase/push() would report a conflict; use cg contribution merge to resolve.

MERGE_IN_PROGRESS class-attribute instance-attribute

MERGE_IN_PROGRESS = 'merge_in_progress'

A cg contribution merge is currently unresolved (MERGE_HEAD exists)--other sync-status classification doesn't apply until it's finished (merge continue) or merge aborted.

CgContributionStatus dataclass

CgContributionStatus(contribution_dir, pushed, contribution_handle, local_title, local_dirty, merge_in_progress, sync_status, local_version, local_draft, local_ready_for_moderation, local_puzzle_type, local_solution_language, local_difficulty, server, moderator_approvals, moderator_denials, status_cache_refreshed_at)

A point-in-time summary of a contribution working directory--see CgContributionManager.status(). Combines purely local facts (sync_status, local_dirty, local_title) with the last-known server state (server/ moderator_approvals/moderator_denials/status_cache_refreshed_at), which is either served from .meta/contribution-status.json (cheap, no network access) or freshly re-fetched first, depending on status(remote=...).

contribution_dir instance-attribute

contribution_dir

The working directory this status describes.

pushed instance-attribute

pushed

Whether this working directory has ever been successfully push()d--i.e. whether contribution_handle is set. If False, server/local_version are always None and sync_status is always NOT_PUSHED.

contribution_handle instance-attribute

contribution_handle

The public handle this working directory tracks, or None if never pushed.

local_title instance-attribute

local_title

data/contribution-data.json's current title, always available once imported/created, regardless of push/sync state.

local_dirty instance-attribute

local_dirty

Whether the working tree currently differs from main's tip (staged or unstaged)--False whenever merge_in_progress is True (not meaningful mid-merge).

merge_in_progress instance-attribute

merge_in_progress

Whether a cg contribution merge is currently unresolved.

sync_status instance-attribute

sync_status

How main currently relates to server--see CgContributionSyncStatus.

local_version instance-attribute

local_version

The server version main last synced with (server's tip's Cg-Version trailer), or None if never pushed. Not necessarily the server's current version unless sync_status is UP_TO_DATE or LOCAL_AHEAD--see server.last_version.version for that, when server is populated fresh (status(remote=True)).

local_draft instance-attribute

local_draft

data/contribution-data.json's draft flag--what's currently on disk (i.e. what the next push() would send), which may differ from server.draft if there are local edits not yet pushed. Always available once imported/created. Prefer this over server.draft for "what will be pushed"--server reflects the server's state as of the last fetch, not necessarily what's currently on disk here.

local_ready_for_moderation instance-attribute

local_ready_for_moderation

data/contribution-data.json's ready_for_moderation flag--see local_draft's docstring; same local-vs-server caveat applies to server.ready_for_moderation.

local_puzzle_type instance-attribute

local_puzzle_type

data/contribution-data.json's puzzle_type (e.g. "PUZZLE_INOUT"), always available once imported/created--see local_draft's docstring for why this (not server. contribution_type) is the one to use for "what will be pushed".

local_solution_language instance-attribute

local_solution_language

data/contribution-data.json's data.solution_language (e.g. "Python3")--the reference solution's language. May be None if a solution hasn't been provided yet. Same local-vs- server rationale as local_puzzle_type--this is versioned (content) state, changed only via push(), not part of CgContributionStatusCache's non-versioned metadata.

local_difficulty instance-attribute

local_difficulty

data/contribution-data.json's data.difficulty (e.g. "easy"). May be None if not set yet. Same local-vs-server rationale as local_puzzle_type/local_solution_language-- versioned content state, not part of CgContributionStatusCache.

server instance-attribute

server

The last-known full, unredacted contribution record from the server (from .meta/ contribution-status.json's contribution field--see CgContributionStatusCache), or None if never pushed or never fetched under a version of this package new enough to write that cache. Reflects the server's state as of status_cache_refreshed_at, which may lag behind local edits--see local_draft/local_ready_for_moderation/local_puzzle_type/ local_solution_language/local_difficulty for what's actually on disk right now.

moderator_approvals instance-attribute

moderator_approvals

Moderators who had cast a "validate" (approve) vote on this contribution's privileged approve/reject moderation gate (Contribution/findContributionModerators) as of status_cache_refreshed_at--3 needed to publish. None under the same conditions as server (never pushed, or never fetched yet). Distinct from the ungated community vote (server.up_votes/down_votes)--never conflate the two.

moderator_denials instance-attribute

moderator_denials

Moderators who had cast a "deny" (reject) vote as of status_cache_refreshed_at--see moderator_approvals's docstring; 3 needed to reject.

status_cache_refreshed_at instance-attribute

status_cache_refreshed_at

When server/moderator_approvals/moderator_denials were captured (.meta/ contribution-status.json's own refreshed_at)--None exactly when those three are None. Always UTC.

CgContributionLocalTestResult dataclass

CgContributionLocalTestResult(ordinal, side, title, passed, updated, input, expected_output, actual_output, stderr, timed_out, returncode, exception=None)

The outcome of running data/solution.src against one local tests/ test case--see CgContributionManager.run_local_test.

ordinal instance-attribute

ordinal

The test case's ordinal directory name (see CgContributionLocalTestCase.ordinal).

side instance-attribute

side

Either "local" or "validator".

title instance-attribute

title

The test case's real title.

passed instance-attribute

passed

In compare mode: whether the run completed without crashing/timing out and its stdout matched expected_output. In update mode: whether the run completed without crashing/ timing out at all (a crashed/timed-out run is never used to overwrite output.txt--there's nothing good to accept as the new baseline).

updated instance-attribute

updated

Whether output.txt was actually overwritten from this run (update mode only--always False in compare mode, and False even in update mode if the run crashed/timed out).

input instance-attribute

input

The test case's input, exactly as fed to the solution's stdin.

expected_output instance-attribute

expected_output

Compare mode: the test case's output.txt content as read before this run. Update mode: the same content this run just wrote to output.txt (i.e. actual_output)--so this field always means "whatever output.txt reads as immediately after this result", in both modes.

actual_output instance-attribute

actual_output

What the solution actually wrote to stdout.

stderr instance-attribute

stderr

What the solution wrote to stderr (not itself a failure condition, but useful context when a test does fail).

timed_out instance-attribute

timed_out

Whether the run was killed for exceeding its timeout rather than running to completion.

returncode instance-attribute

returncode

The subprocess's exit code (0 means it ran without crashing; meaningless--always -1--when timed_out is True, same as CgLocalRunResult.returncode). -1 when exception is set instead (the run never even got this far).

exception class-attribute instance-attribute

exception = None

Set by a caller (not by run_local_test itself, which raises rather than returning a result if something goes genuinely wrong) when a batch runner catches and continues past an unexpected exception for this one test case--see cg contribution play.

CgContributionSetLanguageResult dataclass

CgContributionSetLanguageResult(language, previous_language, wrote_stub)

The outcome of CgContributionManager.set_language().

language instance-attribute

language

The language now recorded in data/contribution-data.json.

previous_language instance-attribute

previous_language

What it was before (None if the contribution had no language set yet).

wrote_stub instance-attribute

wrote_stub

True when a starter data/solution.src was written for the new language; False when that language has no stub to offer and solution.src was left empty for you to fill in (the same thing create() does for such a language).

An empty file rather than a placeholder is correct, not a shortfall: it's sent as a null solutionSource, which makes updateContribution skip solution validation, whereas any non-null one must pass every test case--so a placeholder would block push().

CgContributionLocalTestFailedError

CgContributionLocalTestFailedError(results)

Bases: CgContributionManagerError

Raised by CgContributionManager.run_local_test callers (not by run_local_test itself, which reports one test at a time) to summarize a batch where at least one test case failed. Carries every result (not just the failing ones) via .results.

Source code in codingame_tools/contribution_manager/manager.py
463
464
465
466
467
def __init__(self, results: list[CgContributionLocalTestResult]) -> None:
    self.results = results
    failed = [r for r in results if not r.passed]
    summary = ", ".join(f"{r.ordinal} {r.side} ({r.title})" for r in failed)
    super().__init__(f"{len(failed)}/{len(results)} local test case(s) failed: {summary}")

CgContributionBuildFailedError

CgContributionBuildFailedError(result)

Bases: CgContributionManagerError

Raised by CgContributionManager.run_local_tests when build_solution() failed, so no test case was run at all. Carries the full CgBuildResult (compiler diagnostics in .result.output) via .result.

Note build_solution() itself does not raise this--it returns the result, so a caller driving the build directly can display diagnostics however it likes. This exists for the batch wrapper, which has no other way to say "nothing ran".

Source code in codingame_tools/contribution_manager/manager.py
479
480
481
def __init__(self, result: CgBuildResult) -> None:
    self.result = result
    super().__init__(f"solution failed to build:\n{result.output}")

CgContributionManager

CgContributionManager(contribution_dir, client, *, toolchain_dir=None, mount_root=None, toolchain_languages=None, toolchain_image=None)

Builds/updates a contribution working directory (contribution_dir) against the server, via an already-authenticated CgClient. See the module docstring for the git repo this is backed by.

Source code in codingame_tools/contribution_manager/manager.py
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
def __init__(
            self,
            contribution_dir: Path | str,
            client: CgClient,
            *,
            toolchain_dir: Path | None = None,
            mount_root: Path | None = None,
            toolchain_languages: list[str] | None = None,
            toolchain_image: str | None = None,
        ) -> None:
    # Always resolved to an absolute path: git_repo.py's subprocess calls set `cwd` to
    # `git_dir`/`work_tree` themselves (see CgGitRepo._run), so a relative `contribution_dir`
    # here would make `--git-dir=`/`--work-tree=` (built from it) resolve against the *wrong*
    # cwd inside git's own subprocess--not the caller's original cwd--confirmed live (`cg
    # contribution import ... contribution` from a repo root failed `git init` outright, since
    # the relative `--git-dir=contribution/.meta/...` was interpreted relative to
    # `contribution/data`, not the original cwd).
    self.contribution_dir = Path(contribution_dir).resolve()
    self.client = client
    self.mount_root = Path(mount_root).resolve() if mount_root is not None else None
    self.toolchain_languages = toolchain_languages
    self.toolchain_image = toolchain_image
    self.toolchain_dir = (
            Path(toolchain_dir) if toolchain_dir is not None
            else default_global_data_dir() / TOOLCHAIN_SUBDIR_NAME
        )

mount_root instance-attribute

mount_root = Path(mount_root).resolve() if mount_root is not None else None

Editor workspace root to bind-mount for containerized languages, or None to derive it (see language_context). Normally VS Code's ${workspaceFolder}, passed through by the CLI: cg's own find_workspace_root is a heuristic, and the editor knows the real answer.

identity_file property

identity_file

Path to this working directory's contribution.json (global identity) manifest.

data_dir property

data_dir

Path to this working directory's data/ subdirectory--main's git working tree.

solution_file property

solution_file

The one real solution file, data/solution.<ext>.

Resolved by looking for whatever is actually there rather than by deriving the name from the recorded language: the two can disagree--a working directory written by an older cg still has solution.src, and a fetch can change the language before the rename runs--and the file that exists is the one the user has been editing.

meta_dir property

meta_dir

This working directory's .meta/--always <contribution_dir>/.meta, in both git-dir layouts (see the module docstring). Never inside data/, which holds user state only.

A plain path join, so it never raises and needs no contribution.json to answer. That matters because language_context() needs a meta dir and must stay infallible--cg contribution play works today on a directory holding nothing but data/contribution-data.json, and that must keep working.

meta_file property

meta_file

Path to .meta/contribution-meta.json--see CgContributionMeta.

git_dir property

git_dir

Path to this working directory's git-dir--see _resolve_git_dir for how it is found and the module docstring for the two places it can be.

Raises:

  • FileNotFoundError

    if this working directory has never been imported--nothing to derive it from.

status_cache_file property

status_cache_file

Path to .meta/contribution-status.json (see CgContributionStatusCache). Like meta_dir, and unlike git_dir, this never raises--nothing about its location depends on which git-dir layout is in force.

solution_snapshot_file property

solution_snapshot_file

Path to .meta/solution-snapshot.json--see CgContributionSolutionSnapshot.

selected_test_file property

selected_test_file

Path to .meta/selected-test.json--see CgContributionSelectedTest.

load_meta

load_meta()

This working directory's .meta/contribution-meta.json, or None if it doesn't exist (never created, or .meta/ was deleted) or fails to parse.

Unparseable is treated as absent rather than fatal, like every other .meta/ file: the whole directory is disposable and rebuildable, and git_dir can find the repository on disk without it.

Source code in codingame_tools/contribution_manager/manager.py
906
907
908
909
910
911
912
913
914
915
916
917
918
919
def load_meta(self) -> CgContributionMeta | None:
    """This working directory's `.meta/contribution-meta.json`, or `None` if it doesn't exist
       (never created, or `.meta/` was deleted) or fails to parse.

       Unparseable is treated as absent rather than fatal, like every other `.meta/` file: the
       whole directory is disposable and rebuildable, and `git_dir` can find the repository on
       disk without it."""
    if not self.meta_file.is_file():
        return None
    try:
        return CgContributionMeta.load(self.meta_file)
    except Exception:
        logger.warning("Failed to parse %s--treating as absent.", self.meta_file, exc_info=True)
        return None

server_metadata

server_metadata()

The CgContributionCommitMetadata (version, cover info) at server's current tip, or None if this working directory has never been imported. Public specifically so the CLI can display version numbers without reaching into git_repo/trailer-parsing details.

Source code in codingame_tools/contribution_manager/manager.py
1053
1054
1055
1056
1057
1058
1059
1060
def server_metadata(self) -> CgContributionCommitMetadata | None:
    """The `CgContributionCommitMetadata` (version, cover info) at `server`'s current tip, or
       None if this working directory has never been imported. Public specifically so the CLI
       can display version numbers without reaching into `git_repo`/trailer-parsing details."""
    server_sha = self.git_repo.resolve_ref(SERVER_BRANCH_NAME)
    if server_sha is None:
        return None
    return _trailers_to_metadata(self.git_repo.read_trailers(server_sha))

load_identity

load_identity()

Load contribution.json, or None if this directory has never been imported.

Source code in codingame_tools/contribution_manager/manager.py
1064
1065
1066
1067
1068
def load_identity(self) -> CgContributionIdentity | None:
    """Load `contribution.json`, or None if this directory has never been imported."""
    if not self.identity_file.is_file():
        return None
    return CgContributionIdentity.load(self.identity_file)

load

load()

Load data/contribution-data.json.

Raises:

  • FileNotFoundError

    if this working directory hasn't been imported/initialized yet.

Source code in codingame_tools/contribution_manager/manager.py
1070
1071
1072
1073
1074
1075
1076
def load(self) -> CgContributionView:
    """Load `data/contribution-data.json`.

    Raises:
        FileNotFoundError: if this working directory hasn't been imported/initialized yet.
    """
    return CgContributionView.load(self.contribution_data_file)

save

save(view)

Write view back to data/contribution-data.json, creating data/ if needed.

Source code in codingame_tools/contribution_manager/manager.py
1078
1079
1080
1081
def save(self, view: CgContributionView) -> None:
    """Write `view` back to `data/contribution-data.json`, creating `data/` if needed."""
    self.data_dir.mkdir(parents=True, exist_ok=True)
    view.save(self.contribution_data_file)

read_status_cache

read_status_cache()

Load .meta/contribution-status.json (see _refresh_status_cache), or None if it doesn't exist yet (never fetch()ed/import_()ed under a version of this package new enough to write it) or fails to parse (opportunistic cache, same self-healing spirit as the cover-image reuse in fetch()--corrupt/unreadable is treated as absent, not fatal).

Source code in codingame_tools/contribution_manager/manager.py
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
def read_status_cache(self) -> CgContributionStatusCache | None:
    """Load `.meta/contribution-status.json` (see `_refresh_status_cache`), or None if it
       doesn't exist yet (never `fetch()`ed/`import_()`ed under a version of this package new
       enough to write it) or fails to parse (opportunistic cache, same self-healing spirit as
       the cover-image reuse in `fetch()`--corrupt/unreadable is treated as absent, not fatal).
    """
    path = self.status_cache_file
    if not path.is_file():
        return None
    try:
        return CgContributionStatusCache.load(path)
    except Exception:
        logger.warning("Failed to parse %s--treating as absent.", path, exc_info=True)
        return None

import_ async

import_(contribution_id, *, contribution=None)
Build this working directory from an existing server-side contribution

findContribution (unless contribution is already given), downloading the cover image if one is set, then initializing the git repo with a single shared root commit on both main and server (so git merge-base main server starts out meaningful), plus the corresponding version-data commit. Writes contribution.json if this is a fresh working directory, and .meta/contribution-meta.json either way (repair mode runs precisely when .meta/ went missing, so the record needs rewriting there too).

Also doubles as one of repair()'s two modes: if contribution.json and data/ already exist (e.g. from cloning an outer project that tracks them, or a corrupted/ manually-deleted git-dir) but the git-dir itself is missing, this re-runs the same initialization without overwriting data/'s already-on-disk content for main--only server/version-data are seeded fresh from the current server state. There's no attempt to reconstruct the true historical sync point in this case--nothing durable survives to reconstruct it from; main and server simply start sharing a root again, from right now. See repair()'s docstring for the other mode (no contribution_handle yet at all--this method doesn't handle that one, since it always needs a real contribution_id to fetch).

Raises:

Source code in codingame_tools/contribution_manager/manager.py
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
async def import_(
            self,
            contribution_id: CgContributionId,
            *,
            contribution: CgContribution | None = None,
        ) -> CgContributionView:
    """Build this working directory from an existing server-side contribution:
       `findContribution` (unless `contribution` is already given), downloading the cover
       image if one is set, then initializing the git repo with a single shared root commit
       on both `main` and `server` (so `git merge-base main server` starts out meaningful),
       plus the corresponding `version-data` commit. Writes `contribution.json` if this is a
       fresh working directory, and `.meta/contribution-meta.json` either way (repair mode runs
       precisely when `.meta/` went missing, so the record needs rewriting there too).

       Also doubles as one of `repair()`'s two modes: if `contribution.json` and `data/`
       already exist (e.g. from cloning an outer project that tracks them, or a corrupted/
       manually-deleted git-dir) but the git-dir itself is missing, this re-runs the same
       initialization *without* overwriting `data/`'s already-on-disk content for `main`--only
       `server`/`version-data` are seeded fresh from the current server state. There's no
       attempt to reconstruct the *true* historical sync point in this case--nothing durable
       survives to reconstruct it from; `main` and `server` simply start sharing a root again,
       from right now. See `repair()`'s docstring for the other mode (no `contribution_handle`
       yet at all--this method doesn't handle that one, since it always needs a real
       `contribution_id` to fetch).

    Raises:
        CgContributionManagerError: if this directory already tracks a *different*
                                     contribution, or already has a git repository.
    """
    identity = self.load_identity()
    if identity is not None and identity.contribution_handle != contribution_id:
        raise CgContributionManagerError(
                f"{self.identity_file} already tracks contribution "
                f"{identity.contribution_handle!r}; refusing to import {contribution_id!r} "
                "into the same directory."
            )
    repairing = identity is not None
    self._reject_legacy_layout()
    git_dir = self._resolve_git_dir()
    if git_dir.is_dir():
        raise CgContributionManagerError(
                f"{git_dir} already exists--this working directory has already been imported "
                "(see `cg contribution git` to inspect it directly)."
            )

    if contribution is None:
        contribution = await self.client.services.contribution.find_contribution(contribution_id)
    version = contribution.last_version
    data = version.data

    cover_bytes: bytes | None = None
    if data.cover_binary_id is not None:
        download = await self.client.servlets.file_servlet(data.cover_binary_id)
        cover_bytes = download.content

    if not repairing:
        _materialize_data(
                self.data_dir,
                puzzle_type=contribution.contribution_type,
                draft=version.draft if version.draft is not None else True,
                ready_for_moderation=version.ready_for_moderation if version.ready_for_moderation is not None else False,
                data=data,
                cover_bytes=cover_bytes,
            )
        _write_meta_gitignore(self.contribution_dir)
        self._save_identity(contribution_id)
        _align_solution_file_name(self.contribution_dir, data.solution_language)

    if repairing:
        # Unlike the fresh-import path above (which built data/ itself, via
        # _materialize_data()--always normalized), repairing snapshots whatever's already
        # on disk, preserved as-is from the outer clone. If that on-disk tests/ layout isn't
        # already in the canonical ordinal-dirname form (e.g. it came from an older tool, or
        # from local edits that inserted/reordered directories), this commit's tree would
        # permanently encode that non-canonical layout--and a later fetch()/import_() (always
        # canonical, via _materialize_data()) would then show a spurious diff/conflict against
        # it even when the actual test content never changed. See push()/merge_continue()
        # for the same concern at those other points content ever gets committed from
        # whatever's on disk.
        renormalize_test_case_dirs(self.tests_dir)

    self._save_meta(git_dir)  # both paths: repair mode runs when .meta/ went missing
    init_repo(git_dir, self.data_dir)
    repo = CgGitRepo(git_dir, self.data_dir)
    repo.set_head(MAIN_BRANCH_NAME)

    tree = repo.write_tree_from_worktree()
    message = "Repair from server" if repairing else "Import from server"
    server_sha = self._record_server_commit(repo, tree, contribution, cover_bytes, f"{message} (version {version.version})")
    repo.reset_index_to(server_sha)

    await self._refresh_status_cache(contribution)

    return self.load()

repair async

repair()

Reconstruct this working directory's git-dir from scratch, without disturbing data/'s already-on-disk content--for recovering from a missing or corrupted .meta//git-dir (e.g. an outer project clone that deliberately didn't bring the git-dir along--see the module docstring--or the git-dir having been manually deleted/corrupted).

Two modes, chosen automatically from contribution.json's contribution_handle:

  • Set (this working directory has already been push()d/import_()d before): re-bases off the server--delegates to import_()'s own repair mode, which re-fetches current server state fresh to seed server/version-data's first commit, while main's first commit is built from data/'s current on-disk content, preserved as-is. No attempt is made to reconstruct the true historical sync point--nothing durable survives to reconstruct it from.
  • Not set (this working directory was create()d but never successfully push()d): purely local, no network access at all--just re-establishes main's initial commit from data/'s current on-disk content, the same way create() itself would, but preserving what's already there instead of overwriting it with placeholder content. No server/version-data branches are created; there's no server-side contribution yet to base them on.

Raises:

  • FileNotFoundError

    if this working directory has never been created/imported at all (no contribution.json), or if data/ itself is missing (nothing on disk to repair/preserve).

  • CgContributionManagerError

    if the git-dir already exists (nothing to repair).

Source code in codingame_tools/contribution_manager/manager.py
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
async def repair(self) -> CgContributionView:
    """Reconstruct this working directory's git-dir from scratch, without disturbing `data/`'s
       already-on-disk content--for recovering from a missing or corrupted `.meta/`/git-dir
       (e.g. an outer project clone that deliberately didn't bring the git-dir along--see the
       module docstring--or the git-dir having been manually deleted/corrupted).

       Two modes, chosen automatically from `contribution.json`'s `contribution_handle`:

       - Set (this working directory has already been `push()`d/`import_()`d before): re-bases
         off the server--delegates to `import_()`'s own repair mode, which re-fetches current
         server state fresh to seed `server`/`version-data`'s first commit, while `main`'s
         first commit is built from `data/`'s current on-disk content, preserved as-is. No
         attempt is made to reconstruct the *true* historical sync point--nothing durable
         survives to reconstruct it from.
       - Not set (this working directory was `create()`d but never successfully `push()`d):
         purely local, no network access at all--just re-establishes `main`'s initial commit
         from `data/`'s current on-disk content, the same way `create()` itself would, but
         preserving what's already there instead of overwriting it with placeholder content.
         No `server`/`version-data` branches are created; there's no server-side contribution
         yet to base them on.

    Raises:
        FileNotFoundError: if this working directory has never been created/imported at all
                            (no `contribution.json`), or if `data/` itself is missing (nothing
                            on disk to repair/preserve).
        CgContributionManagerError: if the git-dir already exists (nothing to repair).
    """
    identity = self.load_identity()
    if identity is None:
        raise FileNotFoundError(
                f"{self.identity_file} does not exist--nothing to repair (this working "
                "directory has never been created/imported)."
            )
    if identity.contribution_handle is not None:
        return await self.import_(identity.contribution_handle)

    # Never pushed--purely local reconstruction, no network access, mirroring create()'s own
    # git-init/commit steps but preserving data/'s current on-disk content instead of
    # overwriting it with placeholder content.
    self._reject_legacy_layout()
    git_dir = self._resolve_git_dir()
    if git_dir.is_dir():
        raise CgContributionManagerError(f"{git_dir} already exists--nothing to repair.")
    if not self.data_dir.is_dir():
        raise FileNotFoundError(f"{self.data_dir} does not exist--nothing to repair from.")

    _write_meta_gitignore(self.contribution_dir)
    renormalize_test_case_dirs(self.tests_dir)  # see import_()'s repair mode for why
    # Before the first commit, so the rename lands in the initial tree rather than showing up
    # as an uncommitted change immediately afterwards. This is also the migration path for a
    # working directory an older cg left holding `data/solution.src` plus a root symlink.
    _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)

    self._save_meta(git_dir)
    init_repo(git_dir, self.data_dir)
    repo = CgGitRepo(git_dir, self.data_dir)
    repo.set_head(MAIN_BRANCH_NAME)
    repo.commit_worktree("Initial local content (repaired, not yet pushed to the server)")

    return self.load()

create async

create(*, title, puzzle_type='PUZZLE_INOUT', language='Python3')

Initialize a brand new, purely local contribution working directory--no network access at all (async only for interface consistency with every other method here), and deliberately so: no server-side contribution exists yet, matching how git init never touches a remote either. contribution.json's contribution_handle is left None; the first successful push() fills it in, via createContribution instead of the usual updateContribution--see push()'s docstring for the full create-vs-update story.

Seeds the same placeholder statement/difficulty/test-case content push()'s first call will need (confirmed live that createContribution 500s on a title-only payload--see push())--edit it via the usual sidecar files before that first push. Also seeds contribution-data.json's draft/ready_for_moderation to a private-draft default (True/False)--just a starting value, not locked down: like every other field here, freely editable before the first push, which reads whatever's actually there at that point, the same as any later push.

A real git repo is still initialized here, with an initial commit onto main--local history from before the first push is a normal, supported thing to have (e.g. via cg contribution git), it just isn't reachable from main after that first push succeeds (see push()'s docstring for why, same as every other place in this class that resets main directly onto a freshly-built commit rather than preserving prior lineage).

Refuses upfront if this directory already looks like a contribution working directory in any way--create() itself has no repair mode: a brand new contribution can't already have a matching contribution.json/git-dir from some earlier session, so any pre-existing state here means something is wrong, not something to press on through. If contribution.json exists but the git-dir is missing/corrupted (e.g. this directory was already create()d, possibly even already push()d), use repair() instead.

Parameters:

  • title (str) –

    The new contribution's title.

  • puzzle_type (CgPuzzleType, default: 'PUZZLE_INOUT' ) –

    The type of the contribution. Defaults to "PUZZLE_INOUT" (a standard noninteractive solo puzzle--the only type this package's contribution manager has been exercised against).

  • language (str, default: 'Python3' ) –

    The reference solution's language (see CgSolutionLanguage). Defaults to "Python3". Always gets the solution.<ext> convenience symlink (see _align_solution_file_name) if language maps to a known extension--but data/solution.src itself (the symlink's target) is only pre-populated with a real stub if codingame_tools.language.get_language(language). build_contribution_create_stub_source() returns one (currently only "Python3"); for any other language, the symlink is left dangling until you write data/solution.src yourself.

Raises:

  • CgContributionManagerError

    if this directory already tracks a contribution, or a git-dir already exists at the location this would use.

Source code in codingame_tools/contribution_manager/manager.py
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
async def create(
            self,
            *,
            title: str,
            puzzle_type: CgPuzzleType = "PUZZLE_INOUT",
            language: str = "Python3",
        ) -> CgContributionView:
    """Initialize a brand new, *purely local* contribution working directory--no network
       access at all (`async` only for interface consistency with every other method here),
       and deliberately so: no server-side contribution exists yet, matching
       how `git init` never touches a remote either. `contribution.json`'s
       `contribution_handle` is left `None`; the first successful `push()` fills it in, via
       `createContribution` instead of the usual `updateContribution`--see `push()`'s
       docstring for the full create-vs-update story.

       Seeds the same placeholder statement/difficulty/test-case content `push()`'s first call
       will need (confirmed live that `createContribution` 500s on a title-only payload--see
       `push()`)--edit it via the usual sidecar files before that first push. Also seeds
       `contribution-data.json`'s `draft`/`ready_for_moderation` to a private-draft default
       (`True`/`False`)--just a starting value, not locked down: like every other field here,
       freely editable before the first push, which reads whatever's actually there at that
       point, the same as any later push.

       A real git repo is still initialized here, with an initial commit onto `main`--local
       history from before the first push is a normal, supported thing to have (e.g. via `cg
       contribution git`), it just isn't reachable from `main` after that first push succeeds
       (see `push()`'s docstring for why, same as every other place in this class that resets
       `main` directly onto a freshly-built commit rather than preserving prior lineage).

       Refuses upfront if this directory already looks like a contribution working directory
       in any way--`create()` itself has no repair mode: a *brand new* contribution can't
       already have a matching `contribution.json`/git-dir from some earlier session, so any
       pre-existing state here means something is wrong, not something to press on through. If
       `contribution.json` exists but the git-dir is missing/corrupted (e.g. this directory
       was already `create()`d, possibly even already `push()`d), use `repair()` instead.

    Args:
        title:       The new contribution's title.
        puzzle_type: The type of the contribution. Defaults to "PUZZLE_INOUT" (a standard
                     noninteractive solo puzzle--the only type this package's contribution
                     manager has been exercised against).
        language:    The reference solution's language (see `CgSolutionLanguage`). Defaults
                     to "Python3". Always gets the `solution.<ext>` convenience symlink (see
                     `_align_solution_file_name`) if `language` maps to a known extension--but
                     `data/solution.src` itself (the symlink's target) is only pre-populated
                     with a real stub if `codingame_tools.language.get_language(language).
                     build_contribution_create_stub_source()` returns one (currently only
                     "Python3"); for any other language, the symlink is left dangling until
                     you write `data/solution.src` yourself.

    Raises:
        CgContributionManagerError: if this directory already tracks a contribution, or a
                                     git-dir already exists at the location this would use.
    """
    identity = self.load_identity()
    if identity is not None:
        raise CgContributionManagerError(
                f"{self.identity_file} already exists (tracks contribution "
                f"{identity.contribution_handle!r})--`create()` only makes sense for a brand "
                "new working directory."
            )
    self._reject_legacy_layout()
    git_dir = self._git_dir_for(not is_inside_existing_repo(self.contribution_dir))
    if git_dir.is_dir():
        raise CgContributionManagerError(
                f"{git_dir} already exists, though {self.identity_file} does not--refusing "
                "to create a new contribution into a directory in this inconsistent state."
            )

    # Both the seeded test and validator case are test_in="1"/test_out="1" (see
    # _minimal_valid_contribution_data)--a solution that just echoes its input back trivially
    # passes both. codingame_tools.language.CgLanguage.build_contribution_create_stub_source
    # only returns one for languages with such a trivial stub (currently just Python3); an
    # empty `data/solution.src` isn't meaningfully better than no file at all otherwise (the
    # symlink itself--see _align_solution_file_name--already tells the user exactly where to
    # put their code, for every language, regardless of this).
    solution = await get_language(language).build_contribution_create_stub_source()
    data = dataclasses.replace(
            _starter_contribution_data(title), solution_language=language, solution=solution)
    _materialize_data(
            self.data_dir, puzzle_type=puzzle_type, draft=True, ready_for_moderation=False,
            data=data, cover_bytes=_cover_placeholder_bytes(),
        )
    _write_meta_gitignore(self.contribution_dir)
    self._save_identity(None)
    self._save_meta(git_dir)
    # Record the generated stub, so `set_language()` can tell a brand-new contribution (nothing
    # to lose) from one whose solution.src holds real work.
    self._write_solution_snapshot(language, solution)
    _align_solution_file_name(self.contribution_dir, data.solution_language)

    init_repo(git_dir, self.data_dir)
    repo = CgGitRepo(git_dir, self.data_dir)
    repo.set_head(MAIN_BRANCH_NAME)
    repo.commit_worktree("Initial local content (not yet pushed to the server)")

    return self.load()

push async

push(*, direct_create=False, force=False)

Push this working directory's content to the server, updating server/version-data to reflect the result on success, then auto-committing main to match (its content already matches what was just pushed, by construction).

Deliberately hides a create-vs-update decision that real git never has to make. git push always requires an already-configured remote (git remote add/git push -u first)--pushing establishes no new identity, it only updates one that already exists. This method is different: if this working directory has never been pushed before (i.e. it was built via create(), not import_(), and no push() has succeeded yet), it establishes a contribution on the server first, and on success writes its handle into contribution.json (CgContributionIdentity.contribution_handle, previously None)-- establishing the "remote" implicitly, as a side effect of the very first push, rather than as a separate explicit step. Every later push() against the same working directory takes the normal updateContribution path, exactly like today. This is a deliberate simplification of the git model, chosen specifically so that create() itself never has to call createContribution with placeholder content just to get a handle to import--it stays purely local (see create()'s docstring) until the user has real content ready to push.

The first push is itself two API calls, not one--direct_create opts back into the single-call version. createContribution, unlike updateContribution, has no prevVersion-style idempotency check--if a request succeeds server-side but the response is lost (timeout, network error, and especially the same Cloudflare/524 origin timeout CgContributionServiceHelper.update_contribution already has to recover from for heavy content), there is no reliable way to learn the resulting handle, and blindly retrying risks a genuine duplicate contribution. This risk scales with the size/ complexity of what's being validated--exactly what a first push often has a lot of (hand-written test suites, or, worse, an entire test suite carried over via delete( keep_local=True)'s "use an existing contribution as a template" workflow). So by default, the first push doesn't send the real content to createContribution at all: 1. createContribution is called with a minimal, throwaway, in-memory-only stub (real title, otherwise just enough to be accepted--see _minimal_valid_contribution_data--always a private draft, never for moderation, no cover)--small and fast enough that a 524 here is unlikely in the first place. 2. The returned handle is written into contribution.json immediately, before doing anything else with it--so if step 3 below fails, a retried push() sees contribution_handle already set and raises (see the next paragraph) rather than risking another createContribution call. 3. A commit representing that stub (not the real content) becomes server's first commit, via the same plumbing fetch() uses to build a tree without touching main. 4. The real content is then submitted the normal way--a plain updateContribution call, version 1 -> 2, with CgContributionServiceHelper's existing 524-retry/ polling already protecting it via prevVersion. If this step itself fails/times out, the fix is exactly the same as any other failed push: just run push() again.

Passing direct_create=True skips all of that and calls createContribution once, directly, with the real content--the original, simpler behavior, for callers confident their first push is small/fast enough not to need the extra round trip.

The create-vs-update decision is made from contribution.json's contribution_handle (None => first push), not from whether the server git branch happens to exist-- those two can disagree, and when they do, contribution.json is authoritative: e.g. an outer project clone whose git-dir was deliberately not brought along (see repair()'s docstring) has a real contribution_handle but no server branch yet, and someone could always delete/corrupt the git-dir by hand. Trusting "server branch missing" as "never pushed" in either case would call createContribution again for a contribution that already exists--a duplicate, not a recoverable mistake. So if contribution_handle is already set but server still doesn't resolve, this raises instead of guessing--see Raises below.

A push with nothing to push is a no-op, returning None without contacting the server, because updateContribution has no notion of an empty update: it increments the version and re-runs moderation regardless of whether anything differs. Publishing a new version of identical content costs a review cycle and buries the history of real changes, so it has to be asked for explicitly rather than happening by accident.

"Nothing to push" is decided by comparing the working tree against server's tip tree, which is exactly what the last push or fetch recorded--so it covers every content file including the cover image, and is unaffected by commit metadata. Checked before the cover upload, so an unchanged cover isn't re-uploaded just to discover there was no update to make.

Parameters:

  • direct_create (bool, default: False ) –

    Skip the minimal-stub-first safety step on a first push, and call createContribution once, directly, with the real content--see above. Ignored (has no effect) on anything but a first push.

  • force (bool, default: False ) –

    Push even when the working tree is identical to what the server already has. Ignored on a first push, which always has something to establish.

Returns:

  • CgContribution | None

    The contribution as the server now holds it, or None if there was nothing to push.

Raises:

  • FileNotFoundError

    if this working directory hasn't been created/imported yet.

  • CgContributionManagerError

    if puzzle_type isn't set, if a merge is in progress, or if contribution.json already has a contribution_handle but this working directory's git repo has no server branch (run repair() first--see above).

Source code in codingame_tools/contribution_manager/manager.py
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
async def push(self, *, direct_create: bool = False, force: bool = False) -> CgContribution | None:
    """Push this working directory's content to the server, updating `server`/`version-data`
       to reflect the result on success, then auto-committing `main` to match (its content
       already matches what was just pushed, by construction).

       **Deliberately hides a create-vs-update decision that real git never has to make.**
       `git push` always requires an already-configured remote (`git remote add`/`git push -u`
       first)--pushing establishes no new identity, it only updates one that already exists.
       This method is different: if this working directory has never been pushed before (i.e.
       it was built via `create()`, not `import_()`, and no `push()` has succeeded yet), it
       establishes a contribution on the server first, and on success writes its handle into
       `contribution.json` (`CgContributionIdentity.contribution_handle`, previously `None`)--
       establishing the "remote" implicitly, as a side effect of the very first push, rather
       than as a separate explicit step. Every later `push()` against the same working
       directory takes the normal `updateContribution` path, exactly like today. This is a
       deliberate simplification of the git model, chosen specifically so that `create()`
       itself never has to call `createContribution` with placeholder content just to get a
       handle to import--it stays purely local (see `create()`'s docstring) until the user has
       real content ready to push.

       **The first push is itself two API calls, not one--`direct_create` opts back into the
       single-call version.** `createContribution`, unlike `updateContribution`, has no
       `prevVersion`-style idempotency check--if a request succeeds server-side but the
       response is lost (timeout, network error, and especially the same Cloudflare/524 origin
       timeout `CgContributionServiceHelper.update_contribution` already has to recover
       from for *heavy* content), there is no reliable way to learn the resulting handle, and
       blindly retrying risks a genuine duplicate contribution. This risk scales with the size/
       complexity of what's being validated--exactly what a first push often has a lot of
       (hand-written test suites, or, worse, an entire test suite carried over via `delete(
       keep_local=True)`'s "use an existing contribution as a template" workflow). So by
       default, the first push doesn't send the real content to `createContribution` at all:
       1. `createContribution` is called with a minimal, throwaway, in-memory-only stub (real
          title, otherwise just enough to be accepted--see
          `_minimal_valid_contribution_data`--always a private draft, never for moderation, no
          cover)--small and fast enough that a 524 here is unlikely in the first place.
       2. The returned handle is written into `contribution.json` *immediately*, before doing
          anything else with it--so if step 3 below fails, a retried `push()` sees
          `contribution_handle` already set and raises (see the next paragraph) rather than
          risking another `createContribution` call.
       3. A commit representing that stub (not the real content) becomes `server`'s first
          commit, via the same plumbing `fetch()` uses to build a tree without touching `main`.
       4. The *real* content is then submitted the normal way--a plain `updateContribution`
          call, version 1 -> 2, with `CgContributionServiceHelper`'s existing 524-retry/
          polling already protecting it via `prevVersion`. If this step itself fails/times out,
          the fix is exactly the same as any other failed push: just run `push()` again.

       Passing `direct_create=True` skips all of that and calls `createContribution` once,
       directly, with the real content--the original, simpler behavior, for callers confident
       their first push is small/fast enough not to need the extra round trip.

       The create-vs-update decision is made from `contribution.json`'s `contribution_handle`
       (`None` => first push), *not* from whether the `server` git branch happens to exist--
       those two can disagree, and when they do, `contribution.json` is authoritative: e.g. an
       outer project clone whose git-dir was deliberately not brought along (see `repair()`'s
       docstring) has a real `contribution_handle` but no `server` branch *yet*, and someone
       could always delete/corrupt the git-dir by hand. Trusting "`server` branch missing" as
       "never pushed" in either case would call `createContribution` *again* for a contribution
       that already exists--a duplicate, not a recoverable mistake. So if `contribution_handle`
       is already set but `server` still doesn't resolve, this raises instead of guessing--see
       `Raises` below.

       **A push with nothing to push is a no-op**, returning None without contacting the server,
       because `updateContribution` has no notion of an empty update: it increments the version
       and re-runs moderation regardless of whether anything differs. Publishing a new version
       of identical content costs a review cycle and buries the history of real changes, so it
       has to be asked for explicitly rather than happening by accident.

       "Nothing to push" is decided by comparing the working tree against `server`'s tip *tree*,
       which is exactly what the last push or fetch recorded--so it covers every content file
       including the cover image, and is unaffected by commit metadata. Checked before the cover
       upload, so an unchanged cover isn't re-uploaded just to discover there was no update to
       make.

    Args:
        direct_create: Skip the minimal-stub-first safety step on a first push, and call
                        `createContribution` once, directly, with the real content--see above.
                        Ignored (has no effect) on anything but a first push.
        force:         Push even when the working tree is identical to what the server already
                        has. Ignored on a first push, which always has something to establish.

    Returns:
        The contribution as the server now holds it, or None if there was nothing to push.

    Raises:
        FileNotFoundError: if this working directory hasn't been created/imported yet.
        CgContributionManagerError: if `puzzle_type` isn't set, if a merge is in progress, or
                                     if `contribution.json` already has a `contribution_handle`
                                     but this working directory's git repo has no `server`
                                     branch (run `repair()` first--see above).
    """
    if self.merge_in_progress:
        raise CgContributionManagerError(
                "A merge is in progress (see `cg contribution merge continue`/`abort`)--"
                "resolve or abort it before pushing."
            )
    view = self.load()
    if view.puzzle_type is None:
        raise CgContributionManagerError("Cannot push: puzzle_type is not set in contribution-data.json.")

    # Canonicalize tests/'s ordinal directory names before snapshotting data_dir below: this
    # commit's tree becomes server's new tip verbatim (see the write_tree_from_worktree() call
    # further down), and server's *next* tree (built fresh from a later fetch()/import_(), via
    # _materialize_data()--always canonical) would otherwise show a spurious diff/conflict
    # against a non-canonical layout committed here, even when the actual test content never
    # changed. Content-preserving (only directory names change)--see
    # test_cases_dir.renormalize_test_case_dirs.
    renormalize_test_case_dirs(self.tests_dir)

    identity = self.load_identity()
    assert identity is not None  # merge_in_progress above already required a loadable git_dir
    first_push = identity.contribution_handle is None

    repo = self.git_repo
    server_sha = repo.resolve_ref(SERVER_BRANCH_NAME)
    if not first_push and server_sha is None:
        raise CgContributionManagerError(
                f"{self.identity_file} already tracks contribution "
                f"{identity.contribution_handle!r}, but this working directory's git repo has "
                f"no {SERVER_BRANCH_NAME} branch (missing/corrupted git-dir, or a freshly "
                "cloned outer project that hasn't been repaired yet)--call repair() (`cg "
                "contribution repair`) before pushing."
            )

    if first_push and not direct_create:
        stub_data = _minimal_valid_contribution_data(view.data.title)
        stub_handle = await self.client.services.contribution.helper.create_contribution(
                view.puzzle_type, stub_data, draft=True, ready_for_moderation=False)
        self._write_contribution_handle(stub_handle)  # see the docstring--persisted before find_contribution
        stub_contribution = await self.client.services.contribution.find_contribution(stub_handle)
        with tempfile.TemporaryDirectory(prefix="cg-contribution-stub-") as tmp:
            staging = Path(tmp)
            _materialize_data(
                    staging, puzzle_type=view.puzzle_type, draft=True, ready_for_moderation=False,
                    data=stub_data, cover_bytes=None,
                )
            stub_tree = repo.write_tree_from_dir(staging)
        server_sha = self._record_server_commit(
                repo, stub_tree, stub_contribution, None,
                f"Create placeholder on server (version {stub_contribution.last_version.version})",
            )

    # Only true if direct_create was requested--the stub step above (when it ran) already
    # turned this into an ordinary update, same as any push against an existing contribution.
    needs_direct_create = first_push and direct_create

    # Nothing to push? Say so and stop, before the cover upload below spends a request finding
    # out. server's tip tree is what the last push/fetch recorded, so comparing the worktree
    # against it covers every content file, cover image included.
    if not first_push and not force:
        assert server_sha is not None  # guaranteed by the raise above for a non-first push
        server_tree = repo.rev_parse(f"{server_sha}^{{tree}}", check=False)
        if server_tree is not None and repo.write_tree_from_worktree() == server_tree:
            return None

    # No prior server state to compare a cover image's hash against when creating (directly,
    # or via the stub established above, which never has a cover either)--the empty metadata
    # below just always fails that comparison, forcing a fresh upload, same as any other cover
    # change.
    if needs_direct_create:
        current_metadata = CgContributionCommitMetadata(contribution_id="", version=0)
    else:
        assert server_sha is not None  # the raise above (or the stub step) already ensured this
        current_metadata = _trailers_to_metadata(repo.read_trailers(server_sha))

    cover_path = self.data_dir / COVER_IMAGE_FILE_NAME
    cover_binary_id: int | None
    cover_bytes: bytes | None
    if cover_path.is_file():
        cover_bytes = cover_path.read_bytes()
        cover_content_hash = compute_content_hash(cover_bytes)
        if cover_content_hash == current_metadata.cover_binary_hash and current_metadata.cover_binary_id is not None:
            cover_binary_id = current_metadata.cover_binary_id
        else:
            upload = await self.client.servlets.file_upload(
                    cover_bytes, filename=COVER_IMAGE_FILE_NAME, content_type="image/png")
            cover_binary_id = upload.id
    else:
        cover_binary_id = None
        cover_bytes = None

    local_data, _ = _read_local_data(self.data_dir, view.data)
    data = dataclasses.replace(local_data, cover_binary_id=cover_binary_id)

    if needs_direct_create:
        contribution_id = await self.client.services.contribution.helper.create_contribution(
                view.puzzle_type, data, view.draft, view.ready_for_moderation)
        result = await self.client.services.contribution.find_contribution(contribution_id)
        self._write_contribution_handle(contribution_id)
    else:
        result = await self.client.services.contribution.helper.update_contribution(
                current_metadata.contribution_id,
                view.puzzle_type,
                data,
                view.draft,
                view.ready_for_moderation,
                current_metadata.version,
            )
        result = await self._refresh_active_version(result, current_metadata.contribution_id)

    tree = repo.write_tree_from_worktree()
    new_server_sha = self._record_server_commit(
            repo, tree, result, cover_bytes, f"Push to server (version {result.last_version.version})")
    # main's ref moves directly onto server's new commit (not a separate sibling commit with
    # matching content)--deliberately, so `git merge-base main server` still equals server's
    # tip afterward. A sibling commit here (e.g. via commit_worktree()) would have the *same*
    # tree but a *different* SHA (different parent/message/trailers), leaving merge-base stuck
    # at the pre-push point and making the next rebase()/merge_start() wrongly see "local
    # changed" even though content-wise nothing has, confirmed by direct testing. Uses
    # reset_index_to() rather than a raw update_ref(), so the real index (never touched by
    # write_tree_from_worktree()'s scratch-index tree build) stays in sync with main's new
    # tip too--otherwise a later real `git merge` (merge_start()) reads a stale index. On a
    # first push specifically, this also means any *local-only* history main had before the
    # push (e.g. commits made via `cg contribution git` while drafting) stops being reachable
    # from main's new tip--not deleted, just no longer part of main's ancestry, recoverable via
    # the reflog for as long as it lasts. Same tradeoff already accepted everywhere else this
    # class resets main's ref directly instead of preserving lineage; not special-cased here.
    repo.reset_index_to(new_server_sha)
    return result

fetch async

fetch()

Refresh server's tip from a fresh findContribution, and unconditionally refresh .meta/contribution-status.json (see _refresh_status_cache)--even when the content version hasn't changed, since none of that cache's fields (score/votes/comment count/ views/moderator approve-reject tallies/etc.) are tied to it; only the server/ version-data git commit is skipped in that case. Never touches main, the working tree, or the real index--the fetched content is staged into a throwaway temp directory purely to build a tree object from, so this is safe to call regardless of what's currently on disk in data/.

Reuses the previous cover image's bytes (read straight out of the object database, via server's current tip) rather than re-downloading, if its binary ID is unchanged; self-heals (re-downloads) rather than raising if that reuse ever turns out to be stale/ corrupted--this cache is opportunistic, not sacred.

Raises:

  • FileNotFoundError

    if this working directory has never been imported/committed.

  • CgContributionManagerError

    if a merge is in progress.

Source code in codingame_tools/contribution_manager/manager.py
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
async def fetch(self) -> CgContribution:
    """Refresh `server`'s tip from a fresh `findContribution`, and unconditionally refresh
       `.meta/contribution-status.json` (see `_refresh_status_cache`)--even when the content
       version hasn't changed, since none of that cache's fields (score/votes/comment count/
       views/moderator approve-reject tallies/etc.) are tied to it; only the `server`/
       `version-data` git commit is skipped in that case. Never touches `main`, the working
       tree, or the real index--the fetched content is staged into a throwaway temp directory
       purely to build a tree object from, so this is safe to call regardless of what's
       currently on disk in `data/`.

       Reuses the previous cover image's bytes (read straight out of the object database, via
       `server`'s current tip) rather than re-downloading, if its binary ID is unchanged;
       self-heals (re-downloads) rather than raising if that reuse ever turns out to be stale/
       corrupted--this cache is opportunistic, not sacred.

    Raises:
        FileNotFoundError: if this working directory has never been imported/committed.
        CgContributionManagerError: if a merge is in progress.
    """
    if self.merge_in_progress:
        raise CgContributionManagerError(
                "A merge is in progress (see `cg contribution merge continue`/`abort`)--"
                "resolve or abort it before fetching."
            )
    identity = self.load_identity()
    assert identity is not None  # merge_in_progress above already required a loadable git_dir

    repo = self.git_repo
    server_sha = repo.resolve_ref(SERVER_BRANCH_NAME)
    if server_sha is None:
        raise FileNotFoundError(f"{self.git_dir} has no {SERVER_BRANCH_NAME} branch--nothing to fetch against.")
    current_metadata = _trailers_to_metadata(repo.read_trailers(server_sha))

    contribution = await self.client.services.contribution.find_contribution(current_metadata.contribution_id)
    await self._refresh_status_cache(contribution)
    if contribution.last_version.version == current_metadata.version:
        return contribution  # server's tip already reflects this exact version; status cache still refreshed above

    version = contribution.last_version
    data = version.data
    new_binary_id = data.cover_binary_id
    cover_bytes: bytes | None
    if new_binary_id is None:
        cover_bytes = None
    elif new_binary_id == current_metadata.cover_binary_id:
        cached = repo.read_file_at(SERVER_BRANCH_NAME, COVER_IMAGE_FILE_NAME)
        if cached is not None and compute_content_hash(cached) == current_metadata.cover_binary_hash:
            cover_bytes = cached
        else:
            download = await self.client.servlets.file_servlet(new_binary_id)
            cover_bytes = download.content
    else:
        download = await self.client.servlets.file_servlet(new_binary_id)
        cover_bytes = download.content

    with tempfile.TemporaryDirectory(prefix="cg-contribution-fetch-") as tmp:
        staging = Path(tmp)
        _materialize_data(
                staging,
                puzzle_type=contribution.contribution_type,
                draft=version.draft if version.draft is not None else True,
                ready_for_moderation=version.ready_for_moderation if version.ready_for_moderation is not None else False,
                data=data,
                cover_bytes=cover_bytes,
            )
        tree = repo.write_tree_from_dir(staging)
    self._record_server_commit(repo, tree, contribution, cover_bytes, f"Fetch from server (version {version.version})")
    return contribution

rebase async

rebase()

Detect drift between server and main, and automatically resolve it when that's unambiguous:

  • server unchanged since main last synced: nothing to do, regardless of local edits (CgRebaseStatus.UP_TO_DATE).
  • server changed, main unchanged since it last synced: fast-forward--main gets a new commit matching server's new tip (CgRebaseStatus.FAST_FORWARDED).
  • Both changed: a real conflict, left entirely alone (CgRebaseStatus.CONFLICT)--use cg contribution diff to inspect, and cg contribution merge to resolve.

Raises:

  • FileNotFoundError

    if this working directory has never been imported/committed.

  • CgContributionManagerError

    if a merge is already in progress.

Source code in codingame_tools/contribution_manager/manager.py
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
async def rebase(self) -> CgRebaseStatus:
    """Detect drift between `server` and `main`, and automatically resolve it when that's
       unambiguous:

       - `server` unchanged since `main` last synced: nothing to do, regardless of local edits
         (`CgRebaseStatus.UP_TO_DATE`).
       - `server` changed, `main` unchanged since it last synced: fast-forward--`main` gets a
         new commit matching `server`'s new tip (`CgRebaseStatus.FAST_FORWARDED`).
       - Both changed: a real conflict, left entirely alone (`CgRebaseStatus.CONFLICT`)--use
         `cg contribution diff` to inspect, and `cg contribution merge` to resolve.

    Raises:
        FileNotFoundError: if this working directory has never been imported/committed.
        CgContributionManagerError: if a merge is already in progress.
    """
    if self.merge_in_progress:
        raise CgContributionManagerError(
                "A merge is in progress (see `cg contribution merge continue`/`abort`)--"
                "resolve or abort it before rebasing."
            )
    repo = self.git_repo
    base_before = repo.merge_base(MAIN_BRANCH_NAME, SERVER_BRANCH_NAME)
    if base_before is None:
        raise FileNotFoundError(f"{self.git_dir} has no shared history between main/server--nothing to rebase against.")

    await self.fetch()

    server_after = repo.resolve_ref(SERVER_BRANCH_NAME)
    if server_after == base_before:
        return CgRebaseStatus.UP_TO_DATE
    assert server_after is not None  # can't have changed away from a real SHA to nothing

    main_sha = repo.resolve_ref(MAIN_BRANCH_NAME)
    if main_sha == base_before:
        # A *true* fast-forward: main's ref moves directly onto server's tip, same as real
        # git's own definition--no new commit created. checkout_all() still needs to run to
        # bring the working tree/index along with it.
        repo.checkout_all(SERVER_BRANCH_NAME)
        repo.update_ref(f"refs/heads/{MAIN_BRANCH_NAME}", server_after)
        _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)
        return CgRebaseStatus.FAST_FORWARDED

    return CgRebaseStatus.CONFLICT

merge_discard_local async

merge_discard_local()
unconditionally fetch, then move main's ref directly onto

server's new tip (same as git reset --hard server--no new commit, and deliberately not a new commit with matching content either: a sibling commit here would leave git merge-base main server stuck at the old sync point instead of advancing to server's tip, making the next rebase()/merge_start() wrongly see "local changed" even though nothing would be, confirmed by direct testing--see push()'s docstring for the same reasoning). Any local commits main had are not deleted, just no longer reachable from main itself--recoverable via main's reflog for as long as it lasts. Unlike rebase(), doesn't check whether local actually diverged first--always overwrites. Instant--never touches MERGE_HEAD.

Raises:

  • FileNotFoundError

    if this working directory has never been imported/committed.

  • CgContributionManagerError

    if a merge is already in progress.

Source code in codingame_tools/contribution_manager/manager.py
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
async def merge_discard_local(self) -> CgContributionView:
    """Discard all local edits: unconditionally fetch, then move `main`'s ref directly onto
       `server`'s new tip (same as `git reset --hard server`--no new commit, and deliberately
       *not* a new commit with matching content either: a sibling commit here would leave
       `git merge-base main server` stuck at the old sync point instead of advancing to
       server's tip, making the next `rebase()`/`merge_start()` wrongly see "local changed"
       even though nothing would be, confirmed by direct testing--see `push()`'s docstring
       for the same reasoning). Any local commits `main` had are not deleted, just no longer
       reachable from `main` itself--recoverable via `main`'s reflog for as long as it lasts.
       Unlike `rebase()`, doesn't check whether local actually diverged first--always
       overwrites. Instant--never touches `MERGE_HEAD`.

    Raises:
        FileNotFoundError: if this working directory has never been imported/committed.
        CgContributionManagerError: if a merge is already in progress.
    """
    if self.merge_in_progress:
        raise CgContributionManagerError(
                "A merge is in progress (see `cg contribution merge continue`/`abort`)--"
                "resolve or abort it first."
            )
    await self.fetch()
    repo = self.git_repo
    server_sha = repo.resolve_ref(SERVER_BRANCH_NAME)
    if server_sha is None:
        raise FileNotFoundError(f"{self.git_dir} has no {SERVER_BRANCH_NAME} branch--nothing to discard local changes to.")
    repo.checkout_all(SERVER_BRANCH_NAME)
    repo.update_ref(f"refs/heads/{MAIN_BRANCH_NAME}", server_sha)
    _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)
    return self.load()

merge_discard_server async

merge_discard_server()

Update server to reflect the current server state, without touching main/the working tree at all--just fetch() under a different name, kept as its own method for CLI-naming continuity with the old design (where last_committed/remote were distinct concepts this bridged; they no longer are).

Raises:

  • FileNotFoundError

    if this working directory has never been imported/committed.

  • CgContributionManagerError

    if a merge is already in progress.

Source code in codingame_tools/contribution_manager/manager.py
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
async def merge_discard_server(self) -> CgContribution:
    """Update `server` to reflect the current server state, without touching `main`/the
       working tree at all--just `fetch()` under a different name, kept as its own method for
       CLI-naming continuity with the old design (where `last_committed`/`remote` were
       distinct concepts this bridged; they no longer are).

    Raises:
        FileNotFoundError: if this working directory has never been imported/committed.
        CgContributionManagerError: if a merge is already in progress.
    """
    return await self.fetch()

merge_start async

merge_start()

Begin (or, if one's already in progress, do nothing and report it) a merge:

  1. fetch() (refuses if a merge is already in progress--checked first, so this never runs in that case).
  2. If server's tip already equals git merge-base main server, there's nothing to merge (CgMergeStartStatus.UP_TO_DATE).
  3. Otherwise, a real git merge server against the working tree. If it completes cleanly (including a trivial fast-forward), git has already committed the result-- merge_in_progress is False again, nothing more to do (except renormalizing tests/'s directory layout--see below--and re-generating the solution symlink). If it stops with conflicts, text_conflicts/binary_conflicts (split by content-- see _looks_like_text) list the affected paths; resolve them (by hand, or cg contribution merge interactive) and run merge_continue().

A clean merge's own auto-commit (from git itself) can leave tests/'s ordinal directories in a non-canonical layout (e.g. both sides added test cases using different numbering)--see push()'s docstring for why that matters for a stable round trip with server. So a clean merge here also renormalizes tests/ and folds any resulting rename into that same commit via restage_and_amend_if_dirty(), rather than leaving it for the next push() to silently fix up.

Raises:

  • FileNotFoundError

    if this working directory has never been imported/committed.

Source code in codingame_tools/contribution_manager/manager.py
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
async def merge_start(self) -> CgMergeStartResult:
    """Begin (or, if one's already in progress, do nothing and report it) a merge:

       1. `fetch()` (refuses if a merge is already in progress--checked first, so this never
          runs in that case).
       2. If `server`'s tip already equals `git merge-base main server`, there's nothing to
          merge (`CgMergeStartStatus.UP_TO_DATE`).
       3. Otherwise, a real `git merge server` against the working tree. If it completes
          cleanly (including a trivial fast-forward), git has already committed the result--
          `merge_in_progress` is `False` again, nothing more to do (except renormalizing
          `tests/`'s directory layout--see below--and re-generating the solution symlink).
          If it stops with conflicts, `text_conflicts`/`binary_conflicts` (split by content--
          see `_looks_like_text`) list the affected paths; resolve them (by hand, or `cg
          contribution merge interactive`) and run `merge_continue()`.

       A clean merge's own auto-commit (from git itself) can leave `tests/`'s ordinal
       directories in a non-canonical layout (e.g. both sides added test cases using
       different numbering)--see `push()`'s docstring for why that matters for a stable
       round trip with `server`. So a clean merge here also renormalizes `tests/` and folds
       any resulting rename into that same commit via `restage_and_amend_if_dirty()`, rather
       than leaving it for the next `push()` to silently fix up.

    Raises:
        FileNotFoundError: if this working directory has never been imported/committed.
    """
    if self.merge_in_progress:
        return CgMergeStartResult(status=CgMergeStartStatus.ALREADY_IN_PROGRESS)

    repo = self.git_repo
    await self.fetch()

    base = repo.merge_base(MAIN_BRANCH_NAME, SERVER_BRANCH_NAME)
    server_sha = repo.resolve_ref(SERVER_BRANCH_NAME)
    if server_sha == base:
        return CgMergeStartResult(status=CgMergeStartStatus.UP_TO_DATE)

    clean = repo.merge_branch(SERVER_BRANCH_NAME)
    if clean:
        renormalize_test_case_dirs(self.tests_dir)
        repo.restage_and_amend_if_dirty()
        _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)
        return CgMergeStartResult(status=CgMergeStartStatus.STARTED)

    conflicts = repo.status_conflicts()
    text_conflicts: list[str] = []
    binary_conflicts: list[str] = []
    for rel_path in conflicts:
        content = (self.data_dir / rel_path).read_bytes() if (self.data_dir / rel_path).is_file() else b""
        (text_conflicts if _looks_like_text(content) else binary_conflicts).append(rel_path)
    return CgMergeStartResult(
            status=CgMergeStartStatus.STARTED,
            text_conflicts=tuple(text_conflicts),
            binary_conflicts=tuple(binary_conflicts),
        )

merge_continue

merge_continue()
stage everything (refusing first if a still-unmerged path

still has a leftover <<<<<<< marker--see CgGitRepo.merge_continue) and commit, then renormalize tests/'s ordinal directory layout--see push()'s docstring for why that matters for a stable round trip with server--folding any resulting rename into that same merge commit via restage_and_amend_if_dirty() (done after the merge commit exists, deliberately: renaming a conflicted-but-still-unresolved path before git's own unmerged-index-stage bookkeeping is resolved and committed would confuse status_conflicts(), which looks paths up by their pre-rename name). Refreshes the solution symlink afterward (a resolved contribution-data.json conflict may have changed solution_language).

Raises:

Source code in codingame_tools/contribution_manager/manager.py
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
def merge_continue(self) -> None:
    """Finish an in-progress merge: stage everything (refusing first if a still-unmerged path
       still has a leftover `<<<<<<<` marker--see `CgGitRepo.merge_continue`) and commit, then
       renormalize `tests/`'s ordinal directory layout--see `push()`'s docstring for why
       that matters for a stable round trip with `server`--folding any resulting rename into
       that same merge commit via `restage_and_amend_if_dirty()` (done *after* the merge
       commit exists, deliberately: renaming a conflicted-but-still-unresolved path before
       git's own unmerged-index-stage bookkeeping is resolved and committed would confuse
       `status_conflicts()`, which looks paths up by their pre-rename name). Refreshes the
       solution symlink afterward (a resolved `contribution-data.json` conflict may have
       changed `solution_language`).

    Raises:
        CgContributionManagerError: if no merge is in progress, or (wrapping git's own error)
                                     if unresolved conflict markers remain.
    """
    if not self.merge_in_progress:
        raise CgContributionManagerError("No merge in progress (run `cg contribution merge` to start one).")
    repo = self.git_repo
    try:
        repo.merge_continue()
    except CgGitError as e:
        raise CgContributionManagerError(str(e)) from e
    renormalize_test_case_dirs(self.tests_dir)
    repo.restage_and_amend_if_dirty()
    _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)

merge_abort

merge_abort()
restore main's pre-merge working tree state and discard

MERGE_HEAD. server is left untouched--the merge never reached merge_continue(), so nothing about it was ever recorded anywhere.

Raises:

Source code in codingame_tools/contribution_manager/manager.py
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
def merge_abort(self) -> None:
    """Abort an in-progress merge: restore `main`'s pre-merge working tree state and discard
       `MERGE_HEAD`. `server` is left untouched--the merge never reached `merge_continue()`, so
       nothing about it was ever recorded anywhere.

    Raises:
        CgContributionManagerError: if no merge is in progress.
    """
    if not self.merge_in_progress:
        raise CgContributionManagerError("No merge in progress.")
    self.git_repo.merge_abort()
    _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)

discard_local

discard_local()
reset this working directory's content to match server's

current tip exactly--purely local, no network access at all (unlike merge_discard_local(), which fetch()es fresh first--this uses whatever server already has). Resets both the index and working tree (via CgGitRepo.checkout_all, i.e. git read-tree --reset -u--git checkout <ref> -- . would not remove a file that exists locally but not in server's tree, confirmed by direct testing), without moving main's ref or creating a commit--if main had local commits beyond the last sync, this discards them from the working tree too (matching the old, since-renamed revert()'s "match the last synced state exactly" contract), but they remain recoverable via main's own history, since this never does a hard reset of the ref itself.

Named to match merge_discard_local()/merge_discard_server()'s existing "discard" vocabulary (all three answer "throw away one side and take the other," differing only in whether a merge is in progress and whether they fetch first)--deliberately not revert() (the original name), which collides with real git's very different meaning (a new commit that undoes a past one, preserving history)--and not bare discard(), which reads as "discard the whole contribution" rather than "discard my local edits."

Raises:

  • FileNotFoundError

    if this working directory has never been imported/committed.

  • CgContributionManagerError

    if a merge is in progress.

Source code in codingame_tools/contribution_manager/manager.py
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
def discard_local(self) -> CgContributionView:
    """Discard local edits: reset this working directory's content to match `server`'s
       current tip exactly--purely local, no network access at all (unlike
       `merge_discard_local()`, which `fetch()`es fresh first--this uses whatever `server`
       already has). Resets both the index and working tree (via `CgGitRepo.checkout_all`,
       i.e. `git read-tree --reset -u`--`git checkout <ref> -- .` would *not* remove a file
       that exists locally but not in `server`'s tree, confirmed by direct testing), without
       moving `main`'s ref or creating a commit--if `main` had local commits beyond the last
       sync, this discards them from the working tree too (matching the old, since-renamed
       `revert()`'s "match the last synced state exactly" contract), but they remain
       recoverable via `main`'s own history, since this never does a hard reset of the ref
       itself.

       Named to match `merge_discard_local()`/`merge_discard_server()`'s existing "discard"
       vocabulary (all three answer "throw away one side and take the other," differing only
       in whether a merge is in progress and whether they fetch first)--deliberately not
       `revert()` (the original name), which collides with real git's very different meaning
       (a new commit that undoes a past one, preserving history)--and not bare `discard()`,
       which reads as "discard the whole contribution" rather than "discard my local edits."

    Raises:
        FileNotFoundError: if this working directory has never been imported/committed.
        CgContributionManagerError: if a merge is in progress.
    """
    if self.merge_in_progress:
        raise CgContributionManagerError(
                "A merge is in progress (see `cg contribution merge continue`/`abort`)--"
                "resolve or abort it before discarding local edits."
            )
    repo = self.git_repo
    if repo.resolve_ref(SERVER_BRANCH_NAME) is None:
        raise FileNotFoundError(f"{self.git_dir} has no {SERVER_BRANCH_NAME} branch--nothing to discard to.")
    repo.checkout_all(SERVER_BRANCH_NAME)
    _align_solution_file_name(self.contribution_dir, self.load().data.solution_language)
    return self.load()

delete async

delete(*, keep_local=False, keep_server=False)

Delete this contribution from the server (Contribution/deleteContribution-- unrecoverable), then remove this entire working directory (the default)--or, with keep_local, detach it instead: drop the server/version-data branches and reset contribution.json's contribution_handle back to None, leaving a purely local working directory in exactly the state create() would have left it in, ready for its current content to be pushed as a brand new contribution on the next push() (see push()'s create-vs-update docstring)--e.g. for using an existing contribution as a template for a new one.

keep_server skips the server-side deletion entirely (nothing sent to deleteContribution) and just removes this working directory--for when you only want to stop tracking a contribution locally without touching it on the server. Mutually exclusive with keep_local (together they'd mean "delete nothing," which isn't a delete() at all).

A working directory that was create()d but never successfully push()d has no server-side contribution at all (contribution.json's contribution_handle is None--the authoritative signal here, same as push()'s create-vs-update decision; see that method's docstring for why this is trusted over the server git branch's mere existence)--by default (neither keep_local nor keep_server), that's not an error: there's simply nothing to send to deleteContribution, so this just removes the local working directory, same as it would for any other directory. keep_local and keep_server each DO require a real contribution_handle to exist, though, and raise if not--both are explicit statements about server state (keep_local: "detach from the thing I'm currently tracking"; keep_server: "leave the thing I'm currently tracking alone") that don't make sense to honor silently as no-ops when there's nothing being tracked yet.

main and its commit history (including anything reachable only via the old server/version-data branches, by SHA, until a real git gc eventually collects it) are left untouched by keep_local; only the branches/identity that pointed at the now-deleted contribution are affected.

No confirmation prompt here--that's the CLI's job (cg contribution delete), not this class's (matches every other method here: no interactive behavior, ever).

Raises:

  • FileNotFoundError

    if this working directory has never been created/imported, or (only with keep_local or keep_server) has no contribution_handle yet (create()d but never successfully push()d).

  • CgContributionManagerError

    if a merge is in progress, or both keep_local and keep_server are set.

Source code in codingame_tools/contribution_manager/manager.py
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
async def delete(self, *, keep_local: bool = False, keep_server: bool = False) -> None:
    """Delete this contribution from the server (`Contribution/deleteContribution`--
       unrecoverable), then remove this entire working directory (the default)--or, with
       `keep_local`, detach it instead: drop the `server`/`version-data` branches and reset
       `contribution.json`'s `contribution_handle` back to `None`, leaving a purely local
       working directory in exactly the state `create()` would have left it in, ready for its
       *current* content to be pushed as a brand new contribution on the next `push()` (see
       `push()`'s create-vs-update docstring)--e.g. for using an existing contribution as a
       template for a new one.

       `keep_server` skips the server-side deletion entirely (nothing sent to
       `deleteContribution`) and just removes this working directory--for when you only want
       to stop tracking a contribution locally without touching it on the server. Mutually
       exclusive with `keep_local` (together they'd mean "delete nothing," which isn't a
       `delete()` at all).

       A working directory that was `create()`d but never successfully `push()`d has no
       server-side contribution at all (`contribution.json`'s `contribution_handle` is
       `None`--the authoritative signal here, same as `push()`'s create-vs-update decision;
       see that method's docstring for why this is trusted over the `server` git branch's
       mere existence)--by default (neither `keep_local` nor `keep_server`), that's not an
       error: there's simply nothing to send to `deleteContribution`, so this just removes the
       local working directory, same as it would for any other directory. `keep_local` and
       `keep_server` each DO require a real `contribution_handle` to exist, though, and raise
       if not--both are explicit statements about server state (`keep_local`: "detach from the
       thing I'm currently tracking"; `keep_server`: "leave the thing I'm currently tracking
       alone") that don't make sense to honor silently as no-ops when there's nothing being
       tracked yet.

       `main` and its commit history (including anything reachable only via the old
       `server`/`version-data` branches, by SHA, until a real `git gc` eventually collects it)
       are left untouched by `keep_local`; only the branches/identity that pointed at the
       now-deleted contribution are affected.

       No confirmation prompt here--that's the CLI's job (`cg contribution delete`), not this
       class's (matches every other method here: no interactive behavior, ever).

    Raises:
        FileNotFoundError: if this working directory has never been created/imported, or (only
                            with `keep_local` or `keep_server`) has no `contribution_handle`
                            yet (`create()`d but never successfully `push()`d).
        CgContributionManagerError: if a merge is in progress, or both `keep_local` and
                                     `keep_server` are set.
    """
    if keep_local and keep_server:
        raise CgContributionManagerError(
                "keep_local and keep_server are mutually exclusive--together they'd mean "
                "deleting nothing at all."
            )
    if self.merge_in_progress:
        raise CgContributionManagerError(
                "A merge is in progress (see `cg contribution merge continue`/`abort`)--"
                "resolve or abort it before deleting."
            )
    identity = self.load_identity()
    assert identity is not None  # merge_in_progress above already required a loadable git_dir
    contribution_handle = identity.contribution_handle
    if contribution_handle is None and keep_local:
        raise FileNotFoundError(
                f"{self.identity_file} has no contribution_handle--nothing to detach from "
                "(this working directory was create()d but never successfully pushed)."
            )
    if contribution_handle is None and keep_server:
        raise FileNotFoundError(
                f"{self.identity_file} has no contribution_handle--nothing server-side for "
                "keep_server to leave alone (this working directory was create()d but never "
                "successfully pushed). Omit keep_server to just remove the local working "
                "directory."
            )
    if contribution_handle is not None and not keep_server:
        await self.client.services.contribution.delete_contribution(contribution_handle)

    if keep_local:
        repo = self.git_repo
        repo.delete_ref(f"refs/heads/{SERVER_BRANCH_NAME}")
        repo.delete_ref(f"refs/heads/{VERSION_DATA_BRANCH_NAME}")
        self._write_contribution_handle(None)
    else:
        # See CgPuzzleManager.delete: a containerized language leaves a container bind-mounted
        # to this directory, and its name derives from the path--orphaning one means a future
        # working directory at the same path silently attaches to it.
        await remove_containers_for_root(self.contribution_dir)
        shutil.rmtree(self.contribution_dir)

status async

status(*, remote=False)

A point-in-time summary of this working directory--see CgContributionStatus.

By default, entirely local/cheap: no network access at all--server/ moderator_approvals/moderator_denials (if this working directory has ever been pushed) come straight from .meta/contribution-status.json (see read_status_cache), refreshed on some earlier fetch()/import_()/repair() call. Pass remote=True to fetch() fresh first (same tradeoff as cg contribution diff --remote)--skipped automatically if this working directory has never been pushed (nothing to fetch) or a merge is in progress (fetching mid-merge is refused by fetch() itself); fetch() unconditionally refreshes that cache file (see its docstring), so this always reflects whatever fetch() just saw.

Parameters:

  • remote (bool, default: False ) –

    If True, fetch() fresh from the server before reporting--otherwise reports whatever .meta/contribution-status.json last cached (possibly stale, or entirely absent if this working directory has never been fetched under a version of this package new enough to write it). Defaults to False.

Raises:

  • FileNotFoundError

    if this working directory has never been imported/created.

Source code in codingame_tools/contribution_manager/manager.py
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
async def status(self, *, remote: bool = False) -> CgContributionStatus:
    """A point-in-time summary of this working directory--see `CgContributionStatus`.

       By default, entirely local/cheap: no network access at all--`server`/
       `moderator_approvals`/`moderator_denials` (if this working directory has ever been
       pushed) come straight from `.meta/contribution-status.json` (see `read_status_cache`),
       refreshed on some earlier `fetch()`/`import_()`/`repair()` call. Pass `remote=True` to
       `fetch()` fresh first (same tradeoff as `cg contribution diff --remote`)--skipped
       automatically if this working directory has never been pushed (nothing to fetch) or a
       merge is in progress (fetching mid-merge is refused by `fetch()` itself); `fetch()`
       unconditionally refreshes that cache file (see its docstring), so this always reflects
       whatever `fetch()` just saw.

    Args:
        remote: If True, `fetch()` fresh from the server before reporting--otherwise reports
                whatever `.meta/contribution-status.json` last cached (possibly stale, or
                entirely absent if this working directory has never been fetched under a
                version of this package new enough to write it). Defaults to False.

    Raises:
        FileNotFoundError: if this working directory has never been imported/created.
    """
    identity = self.load_identity()
    if identity is None:
        raise FileNotFoundError(f"{self.identity_file} does not exist--this working directory has never been imported/created.")
    merge_in_progress = self.merge_in_progress
    if remote and not merge_in_progress and identity.contribution_handle is not None:
        await self.fetch()

    repo = self.git_repo
    view = self.load()
    local_title = view.data.title
    local_dirty = False if merge_in_progress else bool(repo.diff_name_status(MAIN_BRANCH_NAME))

    server_sha = repo.resolve_ref(SERVER_BRANCH_NAME)
    metadata = self.server_metadata()
    status_cache = self.read_status_cache()
    server_contribution = status_cache.contribution if status_cache is not None else None
    moderator_approvals = status_cache.moderator_approvals if status_cache is not None else None
    moderator_denials = status_cache.moderator_denials if status_cache is not None else None
    status_cache_refreshed_at = status_cache.refreshed_at if status_cache is not None else None

    sync_status: CgContributionSyncStatus
    if merge_in_progress:
        sync_status = CgContributionSyncStatus.MERGE_IN_PROGRESS
    elif server_sha is None:
        sync_status = CgContributionSyncStatus.NOT_PUSHED
    else:
        base = repo.merge_base(MAIN_BRANCH_NAME, SERVER_BRANCH_NAME)
        main_sha = repo.resolve_ref(MAIN_BRANCH_NAME)
        server_changed = server_sha != base
        local_changed = local_dirty or main_sha != base
        if server_changed and local_changed:
            sync_status = CgContributionSyncStatus.DIVERGED
        elif server_changed:
            sync_status = CgContributionSyncStatus.SERVER_AHEAD
        elif local_changed:
            sync_status = CgContributionSyncStatus.LOCAL_AHEAD
        else:
            sync_status = CgContributionSyncStatus.UP_TO_DATE

    return CgContributionStatus(
            contribution_dir=self.contribution_dir,
            pushed=identity.contribution_handle is not None,
            contribution_handle=identity.contribution_handle,
            local_title=local_title,
            local_dirty=local_dirty,
            merge_in_progress=merge_in_progress,
            sync_status=sync_status,
            local_version=metadata.version if metadata is not None else None,
            local_draft=view.draft,
            local_ready_for_moderation=view.ready_for_moderation,
            local_puzzle_type=view.puzzle_type,
            local_solution_language=view.data.solution_language,
            local_difficulty=view.data.difficulty,
            server=server_contribution,
            moderator_approvals=moderator_approvals,
            moderator_denials=moderator_denials,
            status_cache_refreshed_at=status_cache_refreshed_at,
        )

list_local_tests

list_local_tests(ordinals=None, *, local=True, validator=True)

Enumerate tests/ (see codingame_tools.contribution_manager.test_cases_dir. list_local_test_cases), optionally filtered.

Parameters:

  • ordinals (list[str] | None, default: None ) –

    If given, only test cases whose ordinal matches one of these (by exact string match, or--if both sides are purely numeric--by numeric equality, so "1" matches ordinal directory "01"). Defaults to every ordinal.

  • local (bool, default: True ) –

    Include "local"-side test cases. Defaults to True.

  • validator (bool, default: True ) –

    Include "validator"-side test cases. Defaults to True.

Returns:

Source code in codingame_tools/contribution_manager/manager.py
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
def list_local_tests(
            self,
            ordinals: list[str] | None = None,
            *,
            local: bool = True,
            validator: bool = True,
        ) -> list[CgContributionLocalTestCase]:
    """Enumerate `tests/` (see `codingame_tools.contribution_manager.test_cases_dir.
       list_local_test_cases`), optionally filtered.

    Args:
        ordinals:  If given, only test cases whose ordinal matches one of these (by exact
                   string match, or--if both sides are purely numeric--by numeric equality,
                   so `"1"` matches ordinal directory `"01"`). Defaults to every ordinal.
        local:     Include `"local"`-side test cases. Defaults to True.
        validator: Include `"validator"`-side test cases. Defaults to True.

    Returns:
        Matching test cases, in the same order `list_local_test_cases` returns them.
    """
    test_cases = list_local_test_cases(self.tests_dir)
    if ordinals is not None:
        test_cases = [tc for tc in test_cases if any(_ordinal_matches(o, tc.ordinal) for o in ordinals)]
    if not local:
        test_cases = [tc for tc in test_cases if tc.side != "local"]
    if not validator:
        test_cases = [tc for tc in test_cases if tc.side != "validator"]
    return test_cases

language_context

language_context(solution_language=None, *, mount_root=None)

Describe this working directory to codingame_tools.language--see CgLanguageContext.

mount_root is what a containerized language bind-mounts. It defaults to the enclosing VS Code workspace root, so that in-container paths equal host paths and one container serves the whole workspace; pass it explicitly (VS Code's ${workspaceFolder}) when the real workspace is known, since find_workspace_root is only a guess.

Infallible by design: never requires this directory to have been imported (meta_dir falls back to the non-data/ layout when there's no contribution.json to say otherwise). solution_language is accepted for signature stability but no longer selects a path: there is one real solution file and solution_file finds it whatever extension it carries.

Source code in codingame_tools/contribution_manager/manager.py
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
def language_context(
            self,
            solution_language: CgSolutionLanguage | None = None,
            *,
            mount_root: Path | None = None,
        ) -> CgLanguageContext:
    """Describe this working directory to `codingame_tools.language`--see `CgLanguageContext`.

       `mount_root` is what a containerized language bind-mounts. It defaults to the enclosing
       VS Code workspace root, so that in-container paths equal host paths and one container
       serves the whole workspace; pass it explicitly (VS Code's `${workspaceFolder}`) when the
       real workspace is known, since `find_workspace_root` is only a guess.

       Infallible by design: never requires this directory to have been imported (`meta_dir`
       falls back to the non-`data/` layout when there's no `contribution.json` to say
       otherwise). `solution_language` is accepted for signature stability but no longer selects
       a path: there is one real solution file and `solution_file` finds it whatever extension
       it carries.
    """
    return CgLanguageContext(
            root=self.contribution_dir,
            solution_file=self.solution_file,
            meta_dir=self.meta_dir,
            toolchain_dir=self.toolchain_dir,
            mount_root=mount_root or self.mount_root or find_workspace_root(self.contribution_dir),
            toolchain_languages=self.toolchain_languages,
            toolchain_image=self.toolchain_image,
        )

load_selected_test

load_selected_test()

The explicitly selected test case, or None if none has been chosen.

Source code in codingame_tools/contribution_manager/manager.py
2218
2219
2220
2221
2222
def load_selected_test(self) -> CgContributionSelectedTest | None:
    """The explicitly selected test case, or None if none has been chosen."""
    if not self.selected_test_file.is_file():
        return None
    return CgContributionSelectedTest.load(self.selected_test_file)

select_test

select_test(ordinal, side)

Choose which test case the debugger runs against.

Raises:

Source code in codingame_tools/contribution_manager/manager.py
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
def select_test(self, ordinal: str, side: str) -> None:
    """Choose which test case the debugger runs against.

    Raises:
        CgContributionManagerError: if no test case matches, so a typo surfaces now rather than
                                     when a debug session fails to start.
    """
    matching = self.list_local_tests(
            [ordinal], local=side == "local", validator=side == "validator")
    if not matching:
        available = ", ".join(f"{tc.ordinal}/{tc.side}" for tc in self.list_local_tests()) or "(none)"
        raise CgContributionManagerError(
                f"No {side} test case with ordinal {ordinal!r}. Available: {available}.")
    self.meta_dir.mkdir(parents=True, exist_ok=True)
    CgContributionSelectedTest(ordinal=matching[0].ordinal, side=side).save(self.selected_test_file)

clear_selected_test

clear_selected_test()

Forget the explicit selection, falling back to the default (the first local test).

Source code in codingame_tools/contribution_manager/manager.py
2240
2241
2242
def clear_selected_test(self) -> None:
    """Forget the explicit selection, falling back to the default (the first local test)."""
    self.selected_test_file.unlink(missing_ok=True)

resolve_debug_test

resolve_debug_test()

Which single test a debug session should use: the selection, else the first local test.

Local rather than merely first: validators are the hidden, scoring cases, and landing in a debugger on one by default would be surprising. Falls back to the first test of any side only if there are no local ones at all.

Raises:

Source code in codingame_tools/contribution_manager/manager.py
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
def resolve_debug_test(self) -> CgContributionLocalTestCase:
    """Which single test a debug session should use: the selection, else the first *local* test.

       Local rather than merely first: validators are the hidden, scoring cases, and landing in
       a debugger on one by default would be surprising. Falls back to the first test of any
       side only if there are no local ones at all.

    Raises:
        CgContributionManagerError: if `tests/` holds no test cases.
    """
    all_tests = self.list_local_tests()
    if not all_tests:
        raise CgContributionManagerError(f"No test cases in {self.tests_dir}.")
    selected = self.load_selected_test()
    if selected is not None:
        for test_case in all_tests:
            if test_case.ordinal == selected.ordinal and test_case.side == selected.side:
                return test_case
    locals_first = [tc for tc in all_tests if tc.side == "local"]
    return (locals_first or all_tests)[0]

load_solution_snapshot

load_solution_snapshot()

The starter stub this client last generated into data/solution.src, or None if there isn't one (never generated, or .meta/ predates the snapshot).

Source code in codingame_tools/contribution_manager/manager.py
2265
2266
2267
2268
2269
2270
def load_solution_snapshot(self) -> CgContributionSolutionSnapshot | None:
    """The starter stub this client last generated into `data/solution.src`, or `None` if there
       isn't one (never generated, or `.meta/` predates the snapshot)."""
    if not self.solution_snapshot_file.is_file():
        return None
    return CgContributionSolutionSnapshot.load(self.solution_snapshot_file)

set_language async

set_language(language, *, force=False)

Switch this contribution's reference-solution language, writing a fresh starter stub.

This is destructive in a way the puzzle equivalent is not, and deliberately harder to do by accident. A contribution stores exactly one solution server-side, with no per-language history--unlike a puzzle, where CodinGame keeps your latest source for each language and switching is reversible (see CgTestSessionService.get_previous_code_by_language_id). Here there is nothing to restore and nothing to switch back to: the existing solution is replaced by a stub, and the last durable copy is overwritten as soon as the next push() lands.

So the only non-destructive case is "data/solution.src is still exactly the stub this client generated" (see _solution_is_generated_stub). Notably, matching what the server currently has does not count as safe, unlike the puzzle version--the server copy is precisely what the next push destroys.

Purely local: no network call, because there is no per-language code to fetch.

Parameters:

  • language (CgSolutionLanguage) –

    CodinGame language ID to switch to, e.g. "C++" (see CgSolutionLanguage).

  • force (bool, default: False ) –

    Switch even though a real reference solution would be discarded.

Returns:

Raises:

  • FileNotFoundError

    if this working directory hasn't been imported/initialized.

  • CgContributionManagerError

    if language isn't one this client knows, if it's already the current language, or if a real solution would be lost and force is False.

Source code in codingame_tools/contribution_manager/manager.py
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
async def set_language(
            self,
            language: CgSolutionLanguage,
            *,
            force: bool = False,
        ) -> CgContributionSetLanguageResult:
    """Switch this contribution's reference-solution language, writing a fresh starter stub.

       **This is destructive in a way the puzzle equivalent is not, and deliberately harder to
       do by accident.** A contribution stores exactly one solution server-side, with no
       per-language history--unlike a puzzle, where CodinGame keeps your latest source for each
       language and switching is reversible (see
       `CgTestSessionService.get_previous_code_by_language_id`). Here there is nothing to
       restore and nothing to switch back to: the existing solution is replaced by a stub, and
       the last durable copy is overwritten as soon as the next `push()` lands.

       So the only non-destructive case is "`data/solution.src` is still exactly the stub this
       client generated" (see `_solution_is_generated_stub`). Notably, *matching what the server
       currently has* does **not** count as safe, unlike the puzzle version--the server copy is
       precisely what the next push destroys.

       Purely local: no network call, because there is no per-language code to fetch.

    Args:
        language: CodinGame language ID to switch to, e.g. "C++" (see `CgSolutionLanguage`).
        force:    Switch even though a real reference solution would be discarded.

    Returns:
        A `CgContributionSetLanguageResult`--`wrote_stub` is False when the new language has no
        stub to offer and `solution.src` was removed instead.

    Raises:
        FileNotFoundError: if this working directory hasn't been imported/initialized.
        CgContributionManagerError: if `language` isn't one this client knows, if it's already
                                     the current language, or if a real solution would be lost
                                     and `force` is False.
    """
    view = self.load()
    previous_language = view.data.solution_language
    if language not in list_language_cg_ids():
        raise CgContributionManagerError(
                f"{language!r} isn't a language this client knows. Known languages: "
                f"{', '.join(list_language_cg_ids())}."
            )
    if language == previous_language:
        raise CgContributionManagerError(
                f"{self.contribution_dir} is already using {language!r}--nothing to switch."
            )
    if not force and not self._solution_is_generated_stub(previous_language):
        raise CgContributionManagerError(
                f"{self.solution_file} holds a real {previous_language!r} reference solution. "
                "A contribution stores only ONE solution, with no per-language history, so "
                "switching replaces it with a starter stub and the next `cg contribution push` "
                "overwrites the last durable copy--there is nothing to switch back to. Save it "
                "somewhere outside this working directory first, then pass --force."
            )

    # A language with no stub yields None, written as an *empty* solution.src rather than no
    # file at all--empty is this client's spelling of a null solutionSource (see
    # _read_local_data), so `push()` still skips solution validation while the author keeps a
    # file to type into and a symlink that resolves. Writing a comment-only placeholder instead
    # would be non-null, fail validation, and block the push--see
    # CgLanguage.build_contribution_create_stub_source.
    stub = await get_language(language).build_contribution_create_stub_source()
    _write_sidecar(self.solution_file, stub or "")
    self._write_solution_snapshot(language, stub)
    self.save(dataclasses.replace(
            view, data=dataclasses.replace(view.data, solution_language=language)))
    _align_solution_file_name(self.contribution_dir, language)
    return CgContributionSetLanguageResult(
            language=language, previous_language=previous_language, wrote_stub=stub is not None,
        )

provision_vscode async

provision_vscode(solution_language, *, workspace_root=None, force=False, check=False, debug_adapter_logging=False)

Generate this working directory's VS Code run/debug configuration, if solution_language has any, and write it into the workspace.

What's generated is the same for every working directory of that language, so this is run once per language rather than once per directory, and nothing here goes stale when test cases or the solution language change.

Parameters:

  • solution_language (CgSolutionLanguage) –

    The language data/solution.src is written in.

  • workspace_root (Path | None, default: None ) –

    Where .vscode/ goes. Defaults to find_workspace_root()--VS Code reads launch.json only from the workspace root, which is often not this working directory (see codingame_tools.language.vscode).

  • force (bool, default: False ) –

    Overwrite an existing config file that isn't strict JSON (i.e. uses JSONC comments) instead of refusing.

  • debug_adapter_logging (bool, default: False ) –
                Generate a configuration that logs the debug adapter's own
                 protocol exchange--see `CgVsCodeRequest`.
    
  • check (bool, default: False ) –

    Report what would change without touching anything. This is how staleness is detected: generated entries carry no version stamp, so "would rewriting change anything?" is the whole question, and it stays correct when a future release alters what gets generated.

Returns:

  • list[Path]

    Every path that changed (or, under check, would change), in write order. Empty means

  • list[Path]

    already up to date, or that this language has no VS Code integration.

Raises:

  • CgVsCodeMergeError

    if an existing config file can't be safely merged into.

Source code in codingame_tools/contribution_manager/manager.py
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
async def provision_vscode(
            self,
            solution_language: CgSolutionLanguage,
            *,
            workspace_root: Path | None = None,
            force: bool = False,
            check: bool = False,
            debug_adapter_logging: bool = False,
        ) -> list[Path]:
    """Generate this working directory's VS Code run/debug configuration, if `solution_language`
       has any, and write it into the workspace.

       What's generated is the same for every working directory of that language, so this is run
       once per language rather than once per directory, and nothing here goes stale when test
       cases or the solution language change.

    Args:
        solution_language: The language `data/solution.src` is written in.
        workspace_root:    Where `.vscode/` goes. Defaults to `find_workspace_root()`--VS Code
                            reads `launch.json` only from the workspace *root*, which is often
                            not this working directory (see `codingame_tools.language.vscode`).
        force:             Overwrite an existing config file that isn't strict JSON (i.e. uses
                            JSONC comments) instead of refusing.
        debug_adapter_logging:
                            Generate a configuration that logs the debug adapter's own
                             protocol exchange--see `CgVsCodeRequest`.
        check:             Report what *would* change without touching anything. This is how
                            staleness is detected: generated entries carry no version stamp, so
                            "would rewriting change anything?" is the whole question, and it
                            stays correct when a future release alters what gets generated.

    Returns:
        Every path that changed (or, under `check`, would change), in write order. Empty means
        already up to date, or that this language has no VS Code integration.

    Raises:
        CgVsCodeMergeError: if an existing config file can't be safely merged into.
    """
    resolved_workspace_root = (
            Path(workspace_root).resolve() if workspace_root is not None
            else find_workspace_root(self.contribution_dir)
        )
    request = CgVsCodeRequest(
            ctx=self.language_context(
                    solution_language, mount_root=resolved_workspace_root),
            workspace_root=resolved_workspace_root,
            debug_adapter_logging=debug_adapter_logging,
        )
    provisioning = await get_language(solution_language).build_vscode_provisioning(request)
    if provisioning is None:
        return []
    return write_provisioning(
            provisioning, root=self.contribution_dir,
            workspace_root=resolved_workspace_root, language=solution_language, force=force, dry_run=check)

start_debug_session async

start_debug_session(solution_language, ordinal, side, *, timeout=DEFAULT_BUILD_TIMEOUT_SECONDS)

Get the solution ready for a debugger to attach to, fed by the given test case's input-- see codingame_tools.language.CgLanguage.start_debug_session.

Only meaningful for a language whose debugger attaches to a running target (C++ via gdbserver). Python3's debugger launches the program itself, so it never calls this.

Raises:

  • CgContributionManagerError

    if no test case matches ordinal/side.

  • CgLanguageOperationNotSupportedError

    if this language has no attach-style debugging.

Source code in codingame_tools/contribution_manager/manager.py
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
async def start_debug_session(
            self,
            solution_language: CgSolutionLanguage,
            ordinal: str,
            side: str,
            *,
            timeout: float = DEFAULT_BUILD_TIMEOUT_SECONDS,
        ) -> CgDebugSession:
    """Get the solution ready for a debugger to attach to, fed by the given test case's input--
       see `codingame_tools.language.CgLanguage.start_debug_session`.

       Only meaningful for a language whose debugger attaches to a running target (C++ via
       gdbserver). Python3's debugger launches the program itself, so it never calls this.

    Raises:
        CgContributionManagerError: if no test case matches `ordinal`/`side`.
        CgLanguageOperationNotSupportedError: if this language has no attach-style debugging.
    """
    matching = self.list_local_tests(
            [ordinal], local=side == "local", validator=side == "validator")
    if not matching:
        raise CgContributionManagerError(
                f"No {side} test case with ordinal {ordinal!r} under {self.tests_dir}.")
    ctx = self.language_context(solution_language)
    # `input_text`, not `input_file`: the file carries a final newline this client added,
    # which isn't part of the value and must not reach the solution--see common.text_files.
    return await get_language(solution_language).start_debug_session(
            ctx, matching[0].input_text, timeout=timeout)

stop_debug_session async

stop_debug_session(solution_language)

Tear down whatever start_debug_session() started. Safe to call when nothing is running.

Source code in codingame_tools/contribution_manager/manager.py
2448
2449
2450
2451
2452
async def stop_debug_session(self, solution_language: CgSolutionLanguage) -> None:
    """Tear down whatever `start_debug_session()` started. Safe to call when nothing is
       running."""
    await get_language(solution_language).stop_debug_session(
            self.language_context(solution_language))

build_solution async

build_solution(solution_language, *, profile='run', timeout=DEFAULT_BUILD_TIMEOUT_SECONDS)

Build data/solution.src for local execution, if solution_language needs building at all (Python3 doesn't--this is then an immediate no-op success).

A separate step from run_local_test() so a caller can display build diagnostics apart from program output, report a compile error once rather than once per test case, and give building its own generous timeout. Cheap to call repeatedly: an unchanged source since the last successful build returns up_to_date=True having done nothing.

run_local_tests() calls this for you. A caller driving run_local_test() itself (as cg contribution play does, to stream results) must call this first.

Returns:

  • CgBuildResult

    A CgBuildResult--check .ok; a build failure is reported, never raised.

Source code in codingame_tools/contribution_manager/manager.py
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
async def build_solution(
            self,
            solution_language: CgSolutionLanguage,
            *,
            profile: CgBuildProfile = "run",
            timeout: float = DEFAULT_BUILD_TIMEOUT_SECONDS,
        ) -> CgBuildResult:
    """Build `data/solution.src` for local execution, if `solution_language` needs building at
       all (Python3 doesn't--this is then an immediate no-op success).

       A separate step from `run_local_test()` so a caller can display build diagnostics apart
       from program output, report a compile error once rather than once per test case, and give
       building its own generous timeout. Cheap to call repeatedly: an unchanged source since the
       last successful build returns `up_to_date=True` having done nothing.

       `run_local_tests()` calls this for you. A caller driving `run_local_test()` itself (as
       `cg contribution play` does, to stream results) must call this first.

    Returns:
        A `CgBuildResult`--check `.ok`; a build failure is reported, never raised.
    """
    ctx = self.language_context(solution_language)
    return await get_language(solution_language).build(ctx, profile=profile, timeout=timeout)

run_local_test async

run_local_test(test_case, solution_language, *, update_expected=False, timeout=DEFAULT_RUN_TIMEOUT_SECONDS)

Run data/solution.src against one local test case's input, entirely locally--no network access at all--by shelling out to the appropriate interpreter/compiler as a subprocess (see codingame_tools.language.CgLanguage.run).

Never raises just because the test failed (crashed, timed out, or mismatched)--that's reflected in the returned result's passed, for a caller running a batch of these to collect and report on afterward (see cg contribution play, which is also where "a test raising an unexpected exception" is caught and turned into a result with exception set--this method itself doesn't do that, since it only ever runs one test).

Does not build. For a language that needs compiling, call build_solution() first (run_local_tests() does this for you); this method only runs the already-built artifact.

Parameters:

  • test_case (CgContributionLocalTestCase) –

    Which test case to run (see list_local_tests).

  • solution_language (CgSolutionLanguage) –

    The language to run data/solution.src as (see CgContributionView.data.solution_language).

  • update_expected (bool, default: False ) –

    If True, overwrite test_case.output_file with the solution's actual output instead of comparing against it--for accepting the solution's current behavior as the new known-good baseline. Only written if the run completed without crashing/timing out.

  • timeout (float, default: DEFAULT_RUN_TIMEOUT_SECONDS ) –

    Wall-clock timeout in seconds--see codingame_tools.language. DEFAULT_RUN_TIMEOUT_SECONDS.

Returns:

Raises:

  • CgLanguageOperationNotSupportedError

    if solution_language isn't yet supported by codingame_tools.language.

Source code in codingame_tools/contribution_manager/manager.py
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
async def run_local_test(
            self,
            test_case: CgContributionLocalTestCase,
            solution_language: CgSolutionLanguage,
            *,
            update_expected: bool = False,
            timeout: float = DEFAULT_RUN_TIMEOUT_SECONDS,
        ) -> CgContributionLocalTestResult:
    """Run `data/solution.src` against one local test case's input, entirely locally--no
       network access at all--by shelling out to the appropriate interpreter/compiler as a
       subprocess (see `codingame_tools.language.CgLanguage.run`).

       Never raises just because the test failed (crashed, timed out, or mismatched)--that's
       reflected in the returned result's `passed`, for a caller running a batch of these to
       collect and report on afterward (see `cg contribution play`, which is also where
       "a test raising an unexpected exception" is caught and turned into a result with
       `exception` set--this method itself doesn't do that, since it only ever runs one test).

       **Does not build.** For a language that needs compiling, call `build_solution()` first
       (`run_local_tests()` does this for you); this method only runs the already-built
       artifact.

    Args:
        test_case:         Which test case to run (see `list_local_tests`).
        solution_language: The language to run `data/solution.src` as (see
                            `CgContributionView.data.solution_language`).
        update_expected:   If True, overwrite `test_case.output_file` with the solution's
                            actual output instead of comparing against it--for accepting the
                            solution's current behavior as the new known-good baseline. Only
                            written if the run completed without crashing/timing out.
        timeout:            Wall-clock timeout in seconds--see `codingame_tools.language.
                            DEFAULT_RUN_TIMEOUT_SECONDS`.

    Returns:
        The outcome--see `CgContributionLocalTestResult`.

    Raises:
        CgLanguageOperationNotSupportedError: if `solution_language` isn't yet supported by
                                               `codingame_tools.language`.
    """
    ctx = self.language_context(solution_language)
    run_result = await get_language(solution_language).run(
            ctx, test_case.input_text, timeout=timeout)
    ok = not run_result.timed_out and run_result.returncode == 0
    if update_expected:
        if ok:
            test_case.output_file.write_text(run_result.output, encoding="utf-8")
        expected_output = run_result.output if ok else test_case.output_text
        return CgContributionLocalTestResult(
                ordinal=test_case.ordinal, side=test_case.side, title=test_case.title,
                passed=ok, updated=ok, input=test_case.input_text,
                expected_output=expected_output, actual_output=run_result.output,
                stderr=run_result.stderr, timed_out=run_result.timed_out,
                returncode=run_result.returncode,
            )
    passed = ok and outputs_match(run_result.output, test_case.output_text)
    return CgContributionLocalTestResult(
            ordinal=test_case.ordinal, side=test_case.side, title=test_case.title,
            passed=passed, updated=False, input=test_case.input_text,
            expected_output=test_case.output_text, actual_output=run_result.output,
            stderr=run_result.stderr, timed_out=run_result.timed_out,
            returncode=run_result.returncode,
        )

run_local_tests async

run_local_tests(test_cases, solution_language, *, update_expected=False, timeout=DEFAULT_RUN_TIMEOUT_SECONDS, build_timeout=DEFAULT_BUILD_TIMEOUT_SECONDS)
call build_solution() once, then run every test case in

test_cases (e.g. from list_local_tests) via run_local_test, and raise if any failed--for programmatic callers that just want a pass/fail outcome without cg contribution play's own interleaved per-test console output (which needs its own loop, to catch and continue past an unexpected exception for one test case rather than aborting the whole batch--see the CLI command itself for that version).

Returns:

Raises:

  • CgContributionBuildFailedError

    if the solution failed to build--carries the build output; no test case is run.

  • CgLanguageOperationNotSupportedError

    if solution_language isn't yet supported-- raised immediately, from whichever test case hits it first (every other test case would fail identically, so this doesn't run the rest first).

  • CgContributionLocalTestFailedError

    if any test case failed--carries every result via .results.

Source code in codingame_tools/contribution_manager/manager.py
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
async def run_local_tests(
            self,
            test_cases: list[CgContributionLocalTestCase],
            solution_language: CgSolutionLanguage,
            *,
            update_expected: bool = False,
            timeout: float = DEFAULT_RUN_TIMEOUT_SECONDS,
            build_timeout: float = DEFAULT_BUILD_TIMEOUT_SECONDS,
        ) -> list[CgContributionLocalTestResult]:
    """Convenience batch wrapper: call `build_solution()` once, then run every test case in
       `test_cases` (e.g. from `list_local_tests`) via `run_local_test`, and raise if any
       failed--for programmatic callers that just want a pass/fail outcome without
       `cg contribution play`'s own interleaved per-test console output (which needs its own
       loop, to catch and continue past an unexpected exception for one test case rather than
       aborting the whole batch--see the CLI command itself for that version).

    Returns:
        One `CgContributionLocalTestResult` per test case, in `test_cases`' order.

    Raises:
        CgContributionBuildFailedError: if the solution failed to build--carries the build
                                         output; no test case is run.
        CgLanguageOperationNotSupportedError: if `solution_language` isn't yet supported--
                                               raised immediately, from whichever test case
                                               hits it first (every other test case would fail
                                               identically, so this doesn't run the rest first).
        CgContributionLocalTestFailedError: if any test case failed--carries every result via
                                             `.results`.
    """
    build_result = await self.build_solution(solution_language, timeout=build_timeout)
    if not build_result.ok:
        raise CgContributionBuildFailedError(build_result)
    results = [
            await self.run_local_test(tc, solution_language, update_expected=update_expected, timeout=timeout)
            for tc in test_cases
        ]
    if any(not r.passed for r in results):
        raise CgContributionLocalTestFailedError(results)
    return results

find_solution_file

find_solution_file(data_dir, extension=None)

The existing solution file in data_dir, whatever extension it currently carries.

Callers generally know the language and so know the name, but not always: a working directory whose language changed out from under it, or one written by an older cg that used a fixed solution.src, still has to be found. The expected name wins when present, so a stray leftover can never shadow the real file; otherwise a lone solution.* is accepted.

Returns None if there is no solution file, or if several exist with no way to choose--the caller decides whether that's an error or a thing to repair.

Source code in codingame_tools/contribution_manager/layout.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def find_solution_file(data_dir: Path, extension: str | None = None) -> Path | None:
    """The existing solution file in `data_dir`, whatever extension it currently carries.

       Callers generally know the language and so know the name, but not always: a working
       directory whose language changed out from under it, or one written by an older cg that used
       a fixed `solution.src`, still has to be found. The expected name wins when present, so a
       stray leftover can never shadow the real file; otherwise a lone `solution.*` is accepted.

       Returns None if there is no solution file, or if several exist with no way to choose--the
       caller decides whether that's an error or a thing to repair."""
    if extension is not None:
        expected = data_dir / solution_file_name(extension)
        if expected.is_file():
            return expected
    candidates = sorted(p for p in data_dir.glob(f"{SOLUTION_FILE_STEM}.*") if p.is_file())
    if len(candidates) == 1:
        return candidates[0]
    if not candidates:
        return None
    # Ambiguous: prefer the fallback name if it is one of them, since that is what an older cg
    # wrote and what a migration is most likely looking at.
    fallback = data_dir / solution_file_name(None)
    return fallback if fallback in candidates else None

solution_file_name

solution_file_name(extension)

solution.<ext> for a known language extension, else solution.src.

The file carries the language's real extension rather than a fixed one because every tool that reads it--language servers, debuggers, the compiler--dispatches on the extension. cg previously kept data/solution.src fixed and maintained a solution.<ext> symlink beside it, which cost a day of debugging: the debug info named one path, the editor resolved the other, and breakpoints silently failed to bind. One real file with the right name has no such gap, and needs no symlink support from the filesystem (which Windows only grants with developer mode enabled).

Source code in codingame_tools/contribution_manager/layout.py
60
61
62
63
64
65
66
67
68
69
70
def solution_file_name(extension: str | None) -> str:
    """`solution.<ext>` for a known language extension, else `solution.src`.

       The file carries the language's real extension rather than a fixed one because every tool
       that reads it--language servers, debuggers, the compiler--dispatches on the extension. cg
       previously kept `data/solution.src` fixed and maintained a `solution.<ext>` symlink beside
       it, which cost a day of debugging: the debug info named one path, the editor resolved the
       other, and breakpoints silently failed to bind. One real file with the right name has no
       such gap, and needs no symlink support from the filesystem (which Windows only grants with
       developer mode enabled)."""
    return f"{SOLUTION_FILE_STEM}.{extension or SOLUTION_FALLBACK_EXTENSION}"