EN / field notes OpenHands feature map

OpenHands / F24

Automation Git Sync

Git Sync keeps an organization's automation definitions in a git repository. From the Automate dashboard an admin opens a Git Sync page with a Sync Status card (enabled and encryption pills, repository, branch, path, last synced commit and time, interval, pending changes, Sync now with a live activity row) and a Configuration form (enable switch, interval, repository URL, branch, path, commit author, access token, encryption key). Saving first checks that the repository is reachable; Save and sync now saves and then runs a cycle that commits every changed automation under <path>/<slug>/ and pushes it.

26 mapped behaviors · 26 recipes and supporting checks · source snapshot 9 October 2026
From upstream main at 8793c111. Read the maintained source.

How to get to it

  • Sidebar Automate (sidebar-automations-link), then the Git Sync button in the dashboard header (automations-git-sync). Only users who can manage automations see it (always, locally).
  • Direct URL /automations/git-sync.
  • No command-menu entry, keyboard shortcut or Automate sub-navigation tab leads here; the page shows no Dashboard/Templates tabs (see Gotchas).
  • Leave with Back to Automations (a link, not a button) at the top of the page.

Before you start

Start with the common launch and health checks, then follow this family’s preconditions in order. Recipes share the fixtures and state named below.

Preconditions:

  • Baseline state (fresh launch --new, doctored, onboard --skip). No LLM profile is needed: sync cycles run git only.
  • Git Sync is unconfigured: control-openhands api GET /api/automation/v1/git-sync/status shows "repo_url": "", "enabled": false and "last_synced_commit": null.
  • A local repository to push to: control-openhands fixture git-repo --name qa-sync-remote. Its path output is <repo-path> below ($OH_VERIFY_RUN/workspace/qa-sync-remote). Sync to branch qa-sync, not main: the fixture is a non-bare repo with main checked out, and git refuses pushes to a checked-out branch. Remote state is read with plain git -C <repo-path> ... (read-only second view).
  • An inert automation for the pending-changes bullets, created right before Sync a changed automation: control-openhands api POST /api/automation/v1/preset/prompt --write --data '{"name":"QA_F24 Sync","prompt":"QA git sync fixture; never runs.","trigger":{"type":"event","source":"qa-f24","on":"qa.f24.never"}}'. Its id is <automation-id>; later bullets mark it changed with control-openhands api PATCH /api/automation/v1/<automation-id> --write --data '{"enabled":false}' (or true), which is arrange, not proof. Sync a deletion deletes it again.

Behavior inventory

26 stable behavior IDs and their expected behavior
  • F24.open the Git Sync button on the Automate dashboard and the direct URL open the page (h1 Git Sync); Back to Automations returns to /automations. Read recipe ↓
  • F24.loading a skeleton (git-sync-skeleton) shows while health, permissions and status load. Read recipe ↓
  • F24.overview-unconfigured with nothing configured the card reads Disabled, Not encrypted, Repository Not configured, Branch main, Path automations, Never synced twice, Manual only, Pending 0, and Sync now is disabled. Read recipe ↓
  • F24.form-dirty Save Changes and Save and sync now stay disabled until a field differs from the stored value (reverting disables them again); Save and sync now also needs Enable Git Sync on. Read recipe ↓
  • F24.check-failure a change to repository URL, branch or token is checked first (Checking repository...); an unreachable repo shows Could not reach the repository with these settings with git's output and the hint Fix the settings above, or press Save again to store them anyway., saves nothing, and a second Save stores the values anyway. Read recipe ↓
  • F24.save-and-sync Save and sync now with a reachable repo saves (toast Git Sync settings saved.), then runs a cycle: the activity row goes Syncing... started Ns ago → Sync complete, and the card shows the short commit and Last synced Ns ago. Read recipe ↓
  • F24.sync-now a changed automation raises Pending changes (amber); Sync now shows Syncing... with 1 pending, ends Sync complete, drops Pending to 0 and pushes <path>/<slug>/automation.yaml to the branch. Read recipe ↓
  • F24.sync-failure a failing cycle ends Sync failed and shows the Last sync error banner with git's message and its age; the banner survives a reload and disappears after the next successful cycle. Read recipe ↓
  • F24.encryption saving an encryption key turns the pill to Encrypted and the placeholder to An encryption key is currently set; automations exported afterwards are ciphertext; Clear existing encryption key disables the input and saving it returns to Not encrypted. Read recipe ↓
  • F24.token the access token is a password field that is never shown again; changing it runs the reachability check; Clear existing token disables the input and saves a cleared token. Read recipe ↓
  • F24.author commit author name and email are optional; an invalid email is refused by the browser before any request; saved values are used as the author of the next sync commit (the fields reload blank). Read recipe ↓
  • F24.interval Sync every (seconds) saves N as Every Ns, blank or 0 as Manual only, and refuses negative numbers. Read recipe ↓
  • F24.pause turning Enable Git Sync off and saving (no reachability check) shows Disabled, disables Sync now and keeps the configuration; turning it back on restores Enabled. Read recipe ↓
  • F24.sync-disabled-error Sync now on a page that still shows Enabled after sync was turned off elsewhere shows the toast Enable Git Sync before triggering a sync.; the pill catches up on the next idle poll (≤ 15 s). Read recipe ↓
  • F24.background-sync with an interval set, the backend runs cycles on its own and the open page picks up the new commit on its idle poll, without a reload. Read recipe ↓
  • F24.sync-delete deleting a synced automation raises Pending changes; the next cycle commits the removal of its <path>/<slug>/ folder. Read recipe ↓
  • F24.field-defaults emptying Branch or Path and saving clears the override instead of storing an empty value: the card and inputs fall back to main and automations. Read recipe ↓
  • F24.clear-repo emptying the repository URL is checked (No repository URL is configured.), and a second Save clears it: the card returns to Not configured and Disabled. Read recipe ↓
  • F24.backend-down with the automation service down the page shows Automations Unavailable with Retry; Retry after the service is back shows the page with the stored configuration. Read recipe ↓
  • F24.phone the page fits a 390 px viewport without horizontal overflow; the card's fields stay in two columns. Read recipe ↓
  • F24.unsupported an automation backend without the Git Sync API (status 404) shows Git Sync is not available on this backend with Back to Automations; the dashboard still shows the Git Sync button there. Read recipe ↓
  • F24.no-access a user without manage_automations (Cloud member) sees Git Sync is managed by organization admins, and the dashboard hides the Git Sync button. Read recipe ↓
  • F24.conflict saving a repository, branch and path that another organization already syncs is refused (409) with a toast naming the conflict. Read recipe ↓
  • F24.error-state any other status failure shows the generic automation error panel with Retry. Read recipe ↓

Readable recipes

Read each script from top to bottom. Code is copied from the map; prose gives the action, expected observation, and conditions. <id>, <run> and similar placeholders stand for values from your own run. Short forms such as browser count continue the same control-openhands invocation; they are kept as documented.

Expected observations describe the recipe’s contract. Captures below selected recipes show representative real states from this snapshot; they do not mark every mapped behavior as passed. Follow cleanup before moving to another family.

Open from the dashboard #

  1. Do
    control-openhands browser goto /automations
  2. Check
    control-openhands browser click 'testid=automations-git-sync' --expect-url '/automations/git-sync(\?|$)' --observe 'testid=git-sync-skeleton' --observe-ms 2000
  3. Expect
    The observation lists the skeleton (state "") for a few tens of ms, then <absent>.
  4. Check
    control-openhands browser text 'h1'

    is Git Sync.

  5. Check
    control-openhands browser click 'role=link[name="Back to Automations"]' --expect-url '/automations(\?|$)'
  6. Note
    the URL is /automations.
  7. Do
    control-openhands browser goto /automations/git-sync

    (direct URL) and

  8. Do
    control-openhands browser screenshot --feature F24.open --name page-desktop --full-page
Git Sync settings for repository, interval, credentials and commit author.
The lower form includes manual sync interval, masked credential inputs and save actions. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

Lower Git Sync settings and Save actions are visible.

Inputs remain empty/default; no credentials entered or settings saved.

How this screenshot was taken

agent server: 1.53.0 · automation: 1.19.0 (launcher default) · canvas: 1.26.0

control-openhands browser goto /automations/git-sync
control-openhands browser scroll testid=git-sync-save-button
control-openhands browser screenshot --feature F24.open --name git-sync-settings
Git Sync page with Disabled status and repository configuration fields.
Git Sync shows its status above repository, branch and path configuration. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

Git Sync page opens from the dashboard; no repository is configured and syncing is disabled.

Local configuration view only; no repository saved, encryption changed or sync cycle run. Pending changes are the disabled run-owned dummy definitions.

How this screenshot was taken

agent server: 1.53.0 · automation: 1.19.0 (launcher default) · canvas: 1.26.0

control-openhands browser goto /automations
control-openhands browser click testid=automations-git-sync --expect-url '/automations/git-sync(\?|$)'
control-openhands browser screenshot --feature F24.open --name git-sync

Unconfigured card #

  1. Check
    control-openhands browser text 'role=heading[name="Sync Status"] >> xpath=ancestor::div[2]'
  2. Expect
    It reads Sync Status / Disabled / Not encrypted / Sync now / Repository / Not configured / Branch / main / Path / automations / Last synced commit / Never synced / Last synced / Never synced / Sync every (seconds) / Manual only / Pending changes / 0 (newline-separated).
  3. Check
    control-openhands browser enabled 'testid=git-sync-now-button'

    is false.

Dirty tracking #

  1. Check
    control-openhands browser enabled 'testid=git-sync-save-button'
  2. Note
    and enabled 'testid=git-sync-save-and-sync-button' are both false.
  3. Do
    control-openhands browser fill 'testid=git-sync-interval-input' 300
  4. Note
    : Save is true, Save and sync stays false (sync is off).
  5. Do
    control-openhands browser fill 'testid=git-sync-interval-input' 0
  6. Note
    : Save is false again.
  7. Check
    control-openhands browser click 'testid=git-sync-enabled-switch >> xpath=ancestor::label'
  8. Check
    control-openhands browser eval "document.querySelector('[data-testid=git-sync-enabled-switch]').checked"

    is true and both buttons are true.

  9. Note
    Click the label again; Save is false.

Unreachable repository #

  1. Check
    control-openhands browser fill 'testid=git-sync-repo-url-input' /nonexistent/qa-missing.git
  2. Do
    control-openhands browser click 'testid=git-sync-save-button' --observe 'testid=git-sync-save-button' --observe-ms 3000
  3. Note
    the observation includes Checking repository....
  4. Wait
    control-openhands browser wait 'testid=git-sync-check-failure' --timeout 25000
  5. Check
    control-openhands browser text 'testid=git-sync-check-failure'
  6. Note
    : Could not reach the repository with these settings, git's fatal: '/nonexistent/qa-missing.git' does not appear to be a git repository, then Fix the settings above, or press Save again to store them anyway. Take
  7. Do
    control-openhands browser screenshot 'testid=git-sync-check-failure' --feature F24.check-failure --name failure-block
  8. Check
    control-openhands api GET /api/automation/v1/git-sync/status
  9. Note
    still has "repo_url": "".
  10. Note
    Click testid=git-sync-save-button again, then
  11. Wait
    control-openhands browser wait-text 'Git Sync settings saved.' --timeout 10000
  12. Note
    browser count 'testid=git-sync-check-failure' is 0.
  13. Note
    After
  14. Do
    control-openhands browser reload
  15. Check
    control-openhands browser text 'testid=git-sync-enabled-pill'

    is Enabled (configuring a repo turns sync on) and the card shows /nonexistent/qa-missing.git as plain text (browser count 'testid=git-sync-repo-link' is 0).

Failing cycle #

  1. Do
    control-openhands browser click 'testid=git-sync-now-button' --observe 'testid=git-sync-activity-row' --observe-ms 8000
  2. Expect
    The observation goes Syncing... / started 0s ago (counting up each second) and ends Sync failed;
  3. Check
    control-openhands browser attr 'testid=git-sync-activity-row' data-state

    is failed.

  4. Wait
    control-openhands browser wait 'testid=git-sync-error-banner' --timeout 20000
  5. Check
    control-openhands browser text 'testid=git-sync-error-banner'
  6. Note
    : Last sync error, git command failed (128): git clone --origin origin /nonexistent/qa-missing.git ., fatal: repository '/nonexistent/qa-missing.git' does not exist, Ns ago.
  7. Note
    Screenshot with
  8. Do
    control-openhands browser screenshot --feature F24.sync-failure --name failed
  9. Note
    After
  10. Do
    control-openhands browser reload
  11. Note
    the banner count is 1 and the activity row count is 0 (the row is page-local).

Configure and Save and sync #

  1. Check
    control-openhands browser fill 'testid=git-sync-repo-url-input' "$OH_VERIFY_RUN/workspace/qa-sync-remote"
  2. Do
    control-openhands browser fill 'testid=git-sync-branch-input' qa-sync
  3. Do
    control-openhands browser fill 'testid=git-sync-path-input' qa-automations
  4. Do
    control-openhands browser click 'testid=git-sync-save-and-sync-button' --observe 'testid=git-sync-activity-row' --observe-ms 10000
  5. Expect
    The observation goes Syncing... started 0s ago → Sync complete within a few seconds; browser count 'testid=git-sync-check-failure' and browser count 'testid=git-sync-error-banner' are 0 (a successful cycle clears the last error).
  6. Expect
    The toast Git Sync settings saved. also fires, but it is gone before a 10 s observation ends; to see it, run
  7. Wait
    control-openhands browser wait-text 'Git Sync settings saved.' --timeout 5000
  8. Note
    right after the click instead of --observe.
  9. Expect
    The card text (as in Unconfigured card) shows <repo-path>, qa-sync, qa-automations, a 7-character commit and Last synced / Ns ago.
  10. Do
    git -C <repo-path> log --oneline qa-sync

    shows the fixture's Initial fixture commit (the cycle created the branch).

Sync a changed automation #

  1. Note
    Create QA_F24 Sync (Preconditions),
  2. Do
    control-openhands browser reload
  3. Check
    control-openhands browser text 'text=Pending changes >> xpath=ancestor::div[1]/..'

    (Pending changes / 1) and

  4. Check
    control-openhands browser attr 'text=Pending changes >> xpath=ancestor::div[1]/.. >> span.text-warning' class

    (text-warning).

  5. Note
    Screenshot with
  6. Do
    control-openhands browser screenshot --feature F24.sync-now --name pending
  7. Do
    control-openhands browser click 'testid=git-sync-now-button' --observe 'testid=git-sync-activity-row' --observe-ms 8000
  8. Note
    : Syncing... / started 0s ago / 1 pending, then
  9. Check
    control-openhands browser text 'testid=git-sync-activity-row'

    is Sync complete and Pending changes reads 0.

  10. Do
    git -C <repo-path> log --oneline qa-sync

    has a new Sync automations from agent server commit whose short hash matches the card's Last synced commit, and

  11. Do
    git -C <repo-path> ls-tree -r --name-only qa-sync

    lists qa-automations/qa-f24-sync/automation.yaml and qa-automations/qa-f24-sync/tarball/....

Commit author #

  1. Do
    control-openhands browser fill 'testid=git-sync-author-email-input' not-an-email
  2. Check
    control-openhands browser network --clear
  3. Do
    control-openhands browser click 'testid=git-sync-save-button'
  4. Do
    control-openhands browser eval "document.querySelector('[data-testid=git-sync-author-email-input]').validationMessage"

    (Please include an '@' in the email address. 'not-an-email' is missing an '@'.);

  5. Check
    control-openhands browser network

    lists no git-sync/config request.

  6. Note
    Fill testid=git-sync-author-name-input with 'QA F24 Bot' and testid=git-sync-author-email-input with qa-f24@example.com, click Save,
  7. Wait
    control-openhands browser wait-text 'Git Sync settings saved.' --timeout 10000
  8. Do
    control-openhands browser reload
  9. Check
    control-openhands browser value 'testid=git-sync-author-name-input'

    is "" (the fields never show the stored author).

  10. Note
    Mark the automation changed (PATCH {"enabled":false}),
  11. Do
    control-openhands browser reload
  12. Note
    click testid=git-sync-now-button,
  13. Wait
    control-openhands browser wait '[data-testid=git-sync-activity-row]:not([data-state=running])' --timeout 30000
  14. Do
    git -C <repo-path> log -1 --format='%an <%ae>' qa-sync

    is QA F24 Bot <qa-f24@example.com>.

Encryption key #

  1. Check
    control-openhands browser attr 'testid=git-sync-encryption-key-input' placeholder

    is No encryption key set.

  2. Note
    Fill it with qa-f24-dummy-key, click Save (no Checking repository...: the key is not checked), wait for Git Sync settings saved.,
  3. Do
    control-openhands browser reload
  4. Note
    : browser text 'testid=git-sync-encryption-pill' is Encrypted, the placeholder is An encryption key is currently set and the input value is "".
  5. Note
    Before changing the automation, run
  6. Check
    control-openhands api GET /api/automation/v1/git-sync/status
  7. Note
    record
  8. Do
    git -C <repo-path> rev-parse qa-sync
  9. Note
    click Sync now and wait as in Commit author, then read the head again and
  10. Check
    git -C <repo-path> show qa-sync:qa-automations/qa-f24-sync/automation.yaml | head -c 20
  11. Note
    Expected: the key save alone causes a new commit and ciphertext (gAAAAA); known failure #551: dirty_count is 0, the head is unchanged and the export stays plaintext.
  12. Note
    Record this outcome before any PATCH.
  13. Note
    Continue the separate dirty-export check: mark the automation changed (PATCH {"enabled":true} if the Commit author bullet left it disabled, otherwise {"enabled":false}; api GET /api/automation/v1/git-sync/status must show dirty_count ≥ 1), reload, click Sync now and wait as in Commit author; the same git read starts with gAAAAA.
  14. Expect
    This PATCH arranges a dirty automation; its passing export does not prove the key-save re-export contract.
  15. Note
    Record the encrypted head, fill the key input with a different dummy key, Save, wait for the toast, reload and Sync now without changing the automation.
  16. Note
    Expected: the head and ciphertext change for the new key; known failure #551: dirty_count stays 0 and the head and ciphertext remain unchanged.
  17. Note
    If that failure occurs, arrange ciphertext under the new key with a separate enabled-state PATCH and sync before the clear test; do not count that repair as a key-rotation pass.
  18. Note
    Then fill the key input with any text, run
  19. Do
    control-openhands browser click 'testid=git-sync-clear-encryption-key-switch >> xpath=ancestor::label'
  20. Check
    control-openhands browser enabled 'testid=git-sync-encryption-key-input'

    is false and its value is "".

  21. Note
    Click Save, wait for the toast, reload: the pill is Not encrypted, the placeholder No encryption key set, and the clear switch is unchecked again.
  22. Note
    Record the encrypted head, read git-sync/status, click Sync now and wait without changing the automation, then compare the head and exported file.
  23. Note
    Expected: a new commit contains plaintext; known failure #551: dirty_count is 0, the head is unchanged and ciphertext remains despite the Not encrypted pill.
  24. Note
    Repeat the non-dirty set, rotate and clear checks at browser viewport phone, arranging plaintext before set and ciphertext before rotate/clear; record desktop and phone separately with the actual selected backend in each evidence entry.

Access token #

  1. Check
    control-openhands browser attr 'testid=git-sync-token-input' type

    is password and the placeholder Leave blank to keep the current token.

  2. Note
    Fill it with qa-dummy-token, run
  3. Check
    control-openhands browser network --clear
  4. Note
    click Save with --observe 'testid=git-sync-save-button' --observe-ms 2000 (shows Checking repository...), wait for the toast;
  5. Check
    control-openhands browser network --last 6

    lists /api/automation/v1/git-sync/check then /api/automation/v1/git-sync/config.

  6. Expect
    After reload the token input is "".
  7. Do
    control-openhands browser click 'testid=git-sync-clear-token-switch >> xpath=ancestor::label'
  8. Note
    browser enabled 'testid=git-sync-token-input' is false.
  9. Note
    Click Save, wait for the toast, reload; browser eval "document.querySelector('[data-testid=git-sync-clear-token-switch]').checked" is false.

Interval #

  1. Note
    Fill testid=git-sync-interval-input with 300, Save, wait for the toast, reload: the card's Sync every (seconds) field reads Every 300s (control-openhands browser text 'role=heading[name="Sync Status"] >> xpath=ancestor::div[2]') and the input value is 300.
  2. Note
    Fill it with '', Save, reload: Manual only and input 0.
  3. Note
    Fill -5 and click Save: browser eval "document.querySelector('[data-testid=git-sync-interval-input]').validationMessage" is Value must be greater than or equal to 0.; reload to drop the draft.

Pause and resume #

  1. Note
    Click testid=git-sync-enabled-switch >> xpath=ancestor::label; browser enabled 'testid=git-sync-save-and-sync-button' is false.
  2. Check
    control-openhands browser network --clear
  3. Note
    click Save, wait for the toast;
  4. Check
    control-openhands browser network

    lists no git-sync/check.

  5. Expect
    After reload the pill is Disabled, enabled 'testid=git-sync-now-button' is false and the card still shows <repo-path>; screenshot --feature F24.pause --name disabled.
  6. Expect
    The switch's help text (control-openhands browser text 'testid=git-sync-enabled-switch >> xpath=ancestor::label/following-sibling::p') is Sync is on as soon as a repository is configured. Turn this off to pause syncing without losing the configuration. Click the switch label again, Save, wait for the toast: the pill is Enabled.

Trigger while disabled elsewhere #

  1. Note
    With the page showing Enabled, arrange
  2. Arrange
    control-openhands api PUT /api/automation/v1/git-sync/config --write --data '{"enabled":false}'

    (another admin pausing), then immediately

  3. Do
    control-openhands browser click 'testid=git-sync-now-button'
  4. Wait
    control-openhands browser wait-text 'Enable Git Sync before triggering a sync.' --timeout 5000
  5. Expect
    The pill still says Enabled at first;
  6. Wait
    control-openhands browser wait 'testid=git-sync-enabled-pill >> has-text=Disabled' --timeout 20000
  7. Note
    succeeds within about 15 s. browser errors --app-only lists the induced 503 on /api/automation/v1/git-sync/sync (expected).
  8. Note
    Turn sync back on through the form (switch label, Save).

Repository link #

  1. Note
    Fill testid=git-sync-repo-url-input with https://qa-user:qa-pass@git.example.invalid/qa-org/qa-repo.git, click Save, browser wait 'testid=git-sync-check-failure' --timeout 30000 (git's output redacts the URL as https://***@git.example.invalid/...; the failure itself is CONNECT tunnel failed, response 502 behind the sandbox proxy, a resolve error elsewhere), click Save again, wait for the toast, reload.
  2. Check
    control-openhands browser attr 'testid=git-sync-repo-link' href

    is https://git.example.invalid/qa-org/qa-repo and attr ... target is _blank.

  3. Check
    control-openhands browser text 'testid=git-sync-repo-link'

    is https://git.example.invalid/qa-org/qa-repo.git: the link text drops qa-user:qa-pass@ and keeps .git.

  4. Note
    Screenshot with
  5. Do
    control-openhands browser screenshot 'role=heading[name="Sync Status"] >> xpath=ancestor::div[2]' --feature F24.repo-link --name https-link
  6. Note
    Restore: fill the URL with "$OH_VERIFY_RUN/workspace/qa-sync-remote", Save, wait for the toast; browser count 'testid=git-sync-repo-link' is 0 (a local path is plain text).

Interval cycle #

  1. Note
    Read the current short commit from the card (control-openhands browser text 'text=Last synced commit >> xpath=ancestor::div[1]/..', <old-hash>).
  2. Note
    Fill testid=git-sync-interval-input with 20, Save, wait for the toast; the card reads Every 20s.
  3. Note
    Mark the automation changed (PATCH {"enabled":false}), do not reload, and run
  4. Wait
    control-openhands browser wait 'text="<old-hash>"' --state detached --timeout 60000
  5. Expect
    It succeeds within about 35 s (interval plus the page's 15 s idle poll); the card shows a new hash and Last synced / Ns ago, matching
  6. Do
    git -C <repo-path> log --oneline -1 qa-sync
  7. Note
    Set the interval back to 0 (fill, Save, toast) so later bullets are not raced by background cycles.

Sync a deletion #

  1. Note
    Arrange the deletion (owned by F21):
  2. Arrange
    control-openhands api DELETE /api/automation/v1/<automation-id> --write

    (status 204).

  3. Do
    control-openhands browser reload
  4. Note
    browser text 'text=Pending changes >> xpath=ancestor::div[1]/..' is Pending changes / 1.
  5. Do
    control-openhands browser click 'testid=git-sync-now-button' --observe 'testid=git-sync-activity-row' --observe-ms 6000

    (Syncing... / started 0s ago / 1 pending → Sync complete); Pending changes reads 0.

  6. Do
    git -C <repo-path> log --oneline -1 qa-sync

    is a new Sync automations from agent server commit matching the card's Last synced commit, and

  7. Do
    git -C <repo-path> ls-tree -r --name-only qa-sync

    lists only the fixture's README.md and src/... files: qa-automations/qa-f24-sync/ is gone.

Empty branch and path #

  1. Do
    control-openhands browser fill 'testid=git-sync-branch-input' ''
  2. Note
    and browser fill 'testid=git-sync-path-input' '', then browser click 'testid=git-sync-save-button' --observe 'testid=git-sync-save-button' --observe-ms 2000 (Checking repository...: the branch changed; main exists in the fixture, so the check passes) and browser wait-text 'Git Sync settings saved.' --timeout 10000.
  3. Expect
    After browser reload the card reads Branch main and Path automations, browser value 'testid=git-sync-branch-input' is main and browser value 'testid=git-sync-path-input' is automations;
  4. Check
    control-openhands api GET /api/automation/v1/git-sync/status

    has "branch": "main" and "path": "automations".

  5. Note
    Do not press Sync now here: main is the fixture's checked-out branch.

ssh remote link #

  1. Note
    Fill testid=git-sync-repo-url-input with git@github.com:qa-org/qa-repo.git, click Save, browser wait 'testid=git-sync-check-failure' --timeout 30000 (here git reports ssh: not found; with ssh installed it is an auth or host-key failure), click Save again, wait for the toast, reload.
  2. Check
    control-openhands browser attr 'testid=git-sync-repo-link' href

    is https://github.com/qa-org/qa-repo, attr ... target is _blank and browser text 'testid=git-sync-repo-link' is git@github.com:qa-org/qa-repo.git.

Clear the repository #

  1. Note
    Fill testid=git-sync-repo-url-input with '', click Save,
  2. Wait
    control-openhands browser wait 'testid=git-sync-check-failure' --timeout 25000
  3. Note
    its text is Could not reach the repository with these settings / No repository URL is configured. / Fix the settings above, ....
  4. Note
    Click Save again, wait for the toast, reload: the card reads Disabled and Repository Not configured (branch, path and last commit stay), and the URL input is "" with its placeholder https://github.com/org/repo.git.

Phone layout #

  1. Do
    control-openhands browser viewport phone
  2. Check
    control-openhands browser bbox 'role=heading[name="Sync Status"] >> xpath=ancestor::div[2]'

    (insideViewport true, pageHorizontalOverflow false) and

  3. Check
    control-openhands browser screenshot --feature F24.phone --name status
  4. Note
    : two columns of fields, long paths wrap.
  5. Check
    control-openhands browser scroll 'testid=git-sync-repo-url-input'
  6. Check
    control-openhands browser bbox 'testid=git-sync-save-and-sync-button'

    (pageHorizontalOverflow false) and

  7. Do
    control-openhands browser screenshot --feature F24.phone --name form
  8. Note
    Return with
  9. Do
    control-openhands browser viewport desktop

Automation service down #

  1. Do
    control-openhands service stop automation
  2. Do
    control-openhands browser reload
  3. Wait
    control-openhands browser wait-text 'Automations Unavailable' --timeout 40000
  4. Note
    the page shows the heading, The automations backend is not available right now... and Retry, and no Back link.
  5. Note
    Screenshot with
  6. Do
    control-openhands browser screenshot --feature F24.backend-down --name unavailable
  7. Check
    control-openhands browser network --clear
  8. Do
    control-openhands browser click 'role=button[name="Retry"]'
  9. Check
    control-openhands browser network

    shows a new /api/automation/health request and the panel stays.

  10. Do
    control-openhands restart
  11. Note
    click Retry again and
  12. Wait
    control-openhands browser wait 'testid=git-sync-enabled-pill' --timeout 15000
  13. Note
    : the page is back with the stored configuration.
  14. Check
    control-openhands doctor

    is ok.

Old backend #

  1. Note
    Needs a second, fresh run on an automation release without the Git Sync API (1.7.1 is the last one; 1.8.0 added git_sync/router.py).
  2. Note
    Stop the main run first if memory is short, then
  3. Arrange
    export OH_VERIFY_RUN=$(OH_VERIFY_RUN= control-openhands launch --new --automation-ref 1.7.1 --print-run)

    (uvx installs the tag from GitHub),

  4. Check
    control-openhands doctor

    (ok) and

  5. Do
    control-openhands onboard --skip
  6. Check
    control-openhands api GET /api/automation/v1/git-sync/status

    is 404.

  7. Do
    control-openhands browser goto /automations/git-sync
  8. Wait
    control-openhands browser wait-text 'Git Sync is not available on this backend' --timeout 20000
  9. Note
    the page also reads The automation backend is running a version without the Git Sync API. Update it to a version that supports Git Sync. with Back to Automations below it.
  10. Note
    Screenshot with
  11. Do
    control-openhands browser screenshot --feature F24.unsupported --name unsupported
  12. Check
    control-openhands browser click 'role=link[name="Back to Automations"]' --expect-url '/automations(\?|$)'

    returns to the dashboard, where browser count 'testid=automations-git-sync' is still 1. browser errors --app-only lists only the expected 404 on git-sync/status.

  13. Do
    control-openhands stop
  14. Note
    this run.

No permission · Blocked prerequisite #

  1. Note
    Blocked: local backends always grant manage_automations.
  2. Note
    Needs a Cloud backend signed in as an organization member (not admin or owner); expected Git Sync is managed by organization admins / Only organization admins and owners can view and configure Git Sync. at /automations/git-sync, and
  3. Check
    control-openhands browser count 'testid=automations-git-sync'
  4. Note
    0 on /automations.

Repository taken by another org · Blocked prerequisite #

  1. Note
    Blocked: the local backend has one organization.
  2. Note
    Needs two Cloud orgs; saving the same URL, branch and path in the second shows the error toast Another organization already syncs this repository, branch and path. Sharing them would import each other's automations; use a different repository or path. and keeps the form dirty for a retry.

Status error #

  1. Note
    Not driven: no non-mocked way makes GET /api/automation/v1/git-sync/status fail with a non-404 status while /api/automation/health is ok.
  2. Note
    Expected: the generic automation error panel with Retry.

Clean up #

  1. Expect
    The fixture automation was deleted in Sync a deletion (control-openhands api GET /api/automation/v1 lists none) and Git Sync is cleared by Clear the repository; the fixture repo goes away with the run.
  2. Note
    Check
  3. Check
    control-openhands browser errors --app-only
  4. Note
    on the main run: pageErrors is 0, and only the induced 503 on git-sync/sync from Trigger while disabled elsewhere and the 502s on /api/automation/health, git-sync/status, telemetry/consent and sdk-version while the service was stopped are expected.

Gotchas and known limits

  • Configuring a repository is what turns sync on: the backend's pause switch defaults to on, so the first save of a URL flips the pill to Enabled even though the switch was never touched, and clearing the URL turns it off again. The help text under the switch says so (Sync is on as soon as a repository is configured. ...). A pause is the exception: it survives clearing and re-entering the URL, so the pill stays Disabled until the switch is turned back on.
  • A local path (or file:// URL) is a valid remote, which is what makes this family drivable without credentials. Use a branch other than the fixture's checked-out main: git's default receive.denyCurrentBranch refuses pushes to a non-bare repo's checked-out branch (not driven here; every recipe uses qa-sync).
  • The reachability check runs only when repository URL, branch or token changed (git ls-remote, 20 s timeout). Interval, path, author, encryption key and the enable switch save without it. A check that cannot run at all (old backend, network error) never blocks a save.
  • The second Save after a failed check saves the same values without checking again; editing any checked field re-arms the check.
  • The activity row (git-sync-activity-row, data-state running/succeeded/failed) is page state: it disappears on reload and does not appear for a background cycle that starts and ends between two idle polls (15 s). Assert background cycles through the commit hash, not the row.
  • Toasts (Git Sync settings saved., Enable Git Sync before triggering a sync.) last a few seconds: read them with browser wait-text immediately after the click, never after a long --observe window.
  • Save's label cycles Save Changes → Checking repository... (only when a checked field changed) → Saving... → Save Changes in about 100 ms against a local repo; --observe 'testid=git-sync-save-button' records it.
  • Expected: setting, rotating or clearing an encryption key re-exports every automation: the next sync rewrites plaintext as ciphertext (gAAAAA…), rewrites ciphertext with the new key, or restores plaintext after clearing. Known failure (reproduced 2026-10-08, automation 1.19.0, desktop and phone, selected backend Local): each key-only save leaves dirty_count at 0; Sync now reports Sync complete without a new commit. Setting a key leaves existing files plaintext, rotating retains the old ciphertext, and clearing retains ciphertext despite the Not encrypted pill. A separate automation change does export under the current key setting, but does not satisfy key-only re-export (OpenHands/automation#551, fixed by OpenHands/automation#563 after the 1.19.0 release; re-drive once config/defaults.json versions.automation includes it).
  • Nothing on the page says whether a token is stored: the token placeholder is always Leave blank to keep the current token, and the author fields always reload blank. Prove author changes from the commit (git log --format='%an <%ae>').
  • The card's Repository value drops embedded credentials, as link text and as plain text: an http(s) URL loses its whole user:token@ (a token alone in the user slot too), other schemes keep the user and lose only the password (ssh://git@host:2222/...), and the rest stays as configured. The Repository URL input and GET /api/automation/v1/git-sync/status still carry the credentials, so a screenshot of the form shows them. Never type a real token into the URL.
  • The page has no Automate sub-navigation (Dashboard / Templates) and no command-menu entry; leave through Back to Automations (role=link, not a button).
  • Deleted automations stay in Pending changes until a cycle pushes the removal; after cleanup with sync off the card can read Pending changes 2. Sync a deletion pushes it while the repository is still configured.
  • An untouched Enable Git Sync switch follows the server: after another admin pauses sync, the next idle poll flips both the pill and the switch off, so turning it back on is one label click plus Save.
  • F24.unsupported needs --automation-ref (uvx fetches git+https://github.com/OpenHands/automation@1.7.1); without GitHub access it stays blocked. The control CLI does not forward OH_AUTOMATION_VERSION, so a PyPI release cannot be selected directly.
  • Arrange with api ... --write only to mark automations changed or to simulate another admin; the save, check and sync steps must go through the form.

Source paths: src/routes/automation-git-sync.tsx, src/components/features/automations/git-sync/, src/hooks/query/use-git-sync.ts, src/types/git-sync.ts, src/routes/automations-list.tsx (the Git Sync button).