Git-backed versioning of watched content. The VCS subsystem maintains a forward-only history of file changes in each watch root, with throttled batch commits, AI-generated commit messages, squash retention, and optional remote push.
Git terminology is used throughout, since this is an operator-facing feature.
Each VCS-enabled watch path gets a git repository at its root (the static directory prefix of the glob, e.g. d:/documents for d:/documents/**/*.md), initialized automatically at startup if it does not exist. Several globs with the same root share one repository. This keeps histories isolated: a revert in one root never affects another.
At startup each repository also gets its local git identity (user.name / user.email, from the author setting below) and the always-on .gitignore entries.
File changes are collected into batches rather than committed individually. The commit pipeline uses two controls:
commitThrottleMs (default: 30000): after the first file change, wait this long before committing. New changes during the interval are batched but do not reset the timer.maxBatchSize (default: 1000): if the pending set reaches this size, flush immediately without waiting for the throttle timer. Overflow files roll into the next commit cycle.Commit messages are baseline: batch (<n> files) for commits made during the initial filesystem scan, otherwise an AI-generated message or the template watcher: batch <ISO timestamp> (<n> files).
On shutdown, each root commits its pending files before the service exits.
Reverting a file restores its content from a historical commit via git show, then writes the old content back to disk as a new change. HEAD never moves backward: the restored content becomes a new commit in the forward-only timeline.
VCS receives the same file events as the embedding pipeline: files that match watch.paths, are not excluded by watch.ignored and (with the default watch.respectGitignore: true) are not git-ignored. Events reach VCS before extraction, so a file the watcher cannot embed (e.g. a binary or unsupported format matched by a watch glob) is still versioned. Version history and the vector index keep separate state.
Keep
watch.respectGitignoreenabled with VCS. WithrespectGitignore: false, git-ignored files are still sent to VCS, andgit addrefuses an ignored path: the batch fails, is retried, and eventually trips the circuit breaker.
vcs Block{
"vcs": {
"enabled": true,
"commitThrottleMs": 30000,
"maxBatchSize": 1000,
"branch": "master",
"author": {
"name": "jeeves-watcher",
"email": "watcher@localhost"
},
"commitMessage": {
"enabled": true,
"provider": "anthropic",
"model": "claude-haiku-4-0",
"apiKey": "${ANTHROPIC_API_KEY}"
},
"retention": {
"maxAgeDays": 30,
"maxVersions": 100,
"squashCron": "0 0 * * *"
},
"defaultAccessToken": "${GIT_ACCESS_TOKEN}",
"staleLockThresholdMs": 60000,
"maxConsecutiveFailures": 5,
"circuitBreakerCooldownMs": 300000
}
}
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
Enable VCS tracking globally. |
commitThrottleMs |
number |
30000 |
Throttle interval in milliseconds for batching commits (min: 1000). Timer starts on first change and does not reset. |
maxBatchSize |
number |
1000 |
Max files per commit batch (min: 1). Overflow rolls to next cycle. |
branch |
string |
"master" |
Git branch name for VCS operations. Squash retention and startup orphan recovery target this branch instead of dynamically detecting the current branch. |
author |
object |
See below | Git commit identity, written to each repository's local git config at startup. |
author.name |
string |
"jeeves-watcher" |
Commit author name. |
author.email |
string |
"watcher@localhost" |
Commit author email. |
commitMessage |
object |
See below | AI commit message generation settings. |
commitMessage.enabled |
boolean |
true |
Enable AI-generated commit messages. Falls back to template on failure. |
commitMessage.provider |
string |
"anthropic" |
LLM provider for commit messages. |
commitMessage.model |
string |
"claude-haiku-4-0" |
Model name. |
commitMessage.apiKey |
string |
undefined |
API key. Supports ${ENV_VAR} substitution. Falls back to the OpenClaw gateway config if omitted (see Auth Resolution). |
retention |
object |
See below | Squash retention settings. |
retention.maxAgeDays |
number |
30 |
Squash commits older than this (min: 1). |
retention.maxVersions |
number |
100 |
Keep at most this many commits on top of the squashed baseline (min: 1). |
retention.squashCron |
string |
"0 0 * * *" |
Cron schedule for squash retention (5-field format, host local time). |
defaultAccessToken |
string |
undefined |
Shared access token for remote push. Supports ${ENV_VAR} substitution. |
staleLockThresholdMs |
number |
60000 |
Age in ms after which an index.lock is considered stale and force-removed (min: 5000). |
maxConsecutiveFailures |
number |
5 |
Circuit breaker: trip after this many consecutive commit failures (min: 1). Pending files are retained. |
circuitBreakerCooldownMs |
number |
300000 |
Cooldown in ms before a tripped breaker makes a single recovery attempt (min: 1000). |
A watch path can be an object with a vcs block that overrides VCS settings for that root and adds root-specific remote and accessToken:
{
"watch": {
"paths": [
{
"path": "d:/documents/**/*.md",
"vcs": {
"enabled": true,
"remote": "https://github.com/org/docs-backup.git",
"accessToken": "${DOCS_GIT_TOKEN}",
"commitThrottleMs": 10000,
"maxBatchSize": 500
}
},
{
"path": "d:/logs/**/*.log",
"vcs": {
"enabled": false
}
},
"d:/notes/**/*.txt"
]
}
}
| Field | Type | Default | Description |
|---|---|---|---|
remote |
string |
undefined |
Git remote URL for this root. |
accessToken |
string |
undefined |
Access token for this root's remote. Overrides defaultAccessToken. Supports ${ENV_VAR} substitution. |
String-only watch paths inherit the root-level vcs config. In an object entry's vcs block, the per-root block is validated with the same schema defaults as the root-level block, so every setting that has a default and is omitted takes that default, not the root-level value:
enabled defaults to false: an object entry must set "enabled": true to be versioned, even when vcs.enabled is true.commitThrottleMs, maxBatchSize, branch, staleLockThresholdMs, maxConsecutiveFailures and circuitBreakerCooldownMs likewise fall back to their defaults, so repeat any root-level value you changed.author, commitMessage and retention have no default: when omitted they are inherited from the root-level block (each replaces the root-level object as a whole when set). accessToken falls back to defaultAccessToken.vcs.enabled must still be true at the root level for any root to be versioned.
Caution:
GET /configreturns thewatchblock as loaded, so a per-rootvcs.accessTokenis visible there (after${ENV_VAR}substitution) to any API caller. The root-levelvcsblock (includingdefaultAccessTokenandcommitMessage.apiKey) is never returned. PreferdefaultAccessTokenand protect the API withapi.apiKey.
String config values support ${ENV_VAR} syntax, resolved at startup. A reference to an unset variable is left as the literal text (only api.apiKey and sources fail the startup on an unresolved reference):
{
"vcs": {
"commitMessage": {
"apiKey": "${ANTHROPIC_API_KEY}"
},
"defaultAccessToken": "${GIT_ACCESS_TOKEN}"
}
}
All VCS endpoints are prefixed with /vcs. See the API Reference for the full endpoint documentation alongside the rest of the HTTP API.
The routes are always registered. When vcs.enabled is false, GET /vcs/status reports enabled: false with no roots and every other route answers 404 not_found. When api.apiKey is set they require Authorization: Bearer <key> like every other route except GET /status.
Errors use the standard error body ({ code, error }, with the status derived from code):
| Status | code |
When |
|---|---|---|
400 |
invalid_request |
A required parameter is missing. |
404 |
not_found |
The path or glob is outside every VCS-enabled root; root is not a VCS-enabled root; the file does not exist at the commit (/vcs/show); the .gitignore or the pattern to remove does not exist (/vcs/exclude). |
500 |
internal_error |
An unexpected failure (logged; the response carries generic text, message: "Internal server error"). |
Paths and globs are absolute. A glob is resolved to the root(s) it falls under and passed to git relative to that root as a pathspec, so git's wildcard rules apply: * also matches / (docs/*.md matches nested files too), while docs/**/*.md requires at least one subdirectory.
VCS state for all roots.
curl http://localhost:1936/vcs/status
{
"enabled": true,
"roots": [
{
"path": "d:/documents",
"tracked": 152,
"lastCommit": {
"hash": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"message": "update project notes and readme",
"timestamp": "2026-06-10T14:30:00+00:00"
},
"remoteUrl": "https://github.com/org/docs-backup.git",
"lastPush": "2026-06-10T14:30:05.000Z",
"pushErrors": [],
"breaker": {
"consecutiveFailures": 0,
"tripped": false,
"trippedAt": null,
"lastError": null,
"pendingCount": 0
}
}
]
}
| Field | Type | Description |
|---|---|---|
enabled |
boolean |
Whether any root is versioned. |
roots |
array |
Per-root VCS state. |
roots[].path |
string |
Watch root path. |
roots[].tracked |
number |
Number of commits on the current branch (git rev-list --count HEAD; 0 before the first commit). |
roots[].lastCommit |
object | null |
Most recent commit (null if no commits yet). |
roots[].lastCommit.hash |
string |
Full commit hash. |
roots[].lastCommit.message |
string |
Commit subject. |
roots[].lastCommit.timestamp |
string |
ISO-8601 author date, with the committer's UTC offset. |
roots[].remoteUrl |
string | null |
Configured remote, else the repository's origin URL, else null. |
roots[].lastPush |
string | null |
ISO-8601 timestamp of the last successful push since startup. |
roots[].pushErrors |
array |
Push failures since startup, oldest first, each { timestamp, message } (token redacted). Not cleared by a later successful push. |
roots[].breaker |
object | null |
Commit circuit breaker state (null if the root has no manager). |
roots[].breaker.consecutiveFailures |
number |
Consecutive commit failures since the last success. |
roots[].breaker.tripped |
boolean |
Whether the breaker is tripped (cooling down). |
roots[].breaker.trippedAt |
string | null |
ISO-8601 timestamp of the current trip. |
roots[].breaker.lastError |
string | null |
Message of the most recent commit failure. |
roots[].breaker.pendingCount |
number |
Files queued or in flight, not yet committed. |
Commit history for files matching a glob.
curl "http://localhost:1936/vcs/history?glob=d:/documents/*.md&limit=10&since=2026-06-01T00:00:00Z"
| Param | Type | Default | Description |
|---|---|---|---|
glob |
string |
Required | Path or glob pattern to filter history. |
since |
string |
undefined |
Lower date bound, passed to git log --since (ISO-8601 or any git date string). |
until |
string |
undefined |
Upper date bound, passed to git log --until. |
limit |
number |
20 |
Max entries to return. |
[
{
"commit": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"message": "update project notes and readme",
"timestamp": "2026-06-10T14:30:00+00:00",
"files": ["projects/readme.md", "projects/notes.md"]
},
{
"commit": "e4f5a6b7c8d90a1b2c3d4e5f60718293a4b5c6d7",
"message": "add meeting transcript",
"timestamp": "2026-06-09T09:15:00+00:00",
"files": ["meetings/2026-06-09.md"]
}
]
| Field | Type | Description |
|---|---|---|
commit |
string |
Full commit hash. |
message |
string |
Commit subject. |
timestamp |
string |
ISO-8601 author date. |
files |
string[] |
Files changed in this commit that match the glob, relative to the root. |
When the glob covers several roots, their entries are merged, sorted newest first and cut to limit; entries do not name their root. A root whose git log fails (e.g. no commits yet) contributes nothing.
Retrieve file content at a specific commit.
curl "http://localhost:1936/vcs/show?path=d:/documents/readme.md&commit=a1b2c3d"
| Param | Type | Description |
|---|---|---|
path |
string |
Required. Absolute file path. |
commit |
string |
Required. Commit hash (full or abbreviated) or any git revision. |
The file content as text, with a Content-Type chosen from the extension (.txt, .md, .json, .xml, .html, .css, .js, .ts, .yaml/.yml, .csv; anything else is application/octet-stream). The content is decoded as UTF-8, so binary files are not returned byte-for-byte. 404 not_found when the file does not exist at that commit (or the commit does not exist).
Unified diff between commits for files matching a glob.
curl "http://localhost:1936/vcs/diff?glob=d:/documents/*.md&commit=a1b2c3d&commitEnd=e4f5a6b"
| Param | Type | Description |
|---|---|---|
glob |
string |
Required. Path or glob pattern to filter files. |
commit |
string |
Required. Start commit. |
commitEnd |
string |
End commit. Defaults to HEAD if omitted. |
Unified diff text (Content-Type: text/plain) of git diff <commit>..<commitEnd>, concatenated across the roots the glob covers. An unknown commit (or a range with no changes) yields an empty 200 body, not an error. A glob outside every root is 404 not_found.
Restore files from a historical commit (forward-only: writes old content as new changes).
curl -X POST http://localhost:1936/vcs/revert \
-H "Content-Type: application/json" \
-d '{
"glob": "d:/documents/projects",
"commit": "a1b2c3d",
"existingOnly": true
}'
Body schema:
{
glob: string; // File or directory to revert (absolute)
commit: string; // Commit to restore from
existingOnly?: boolean; // Only restore files that currently exist on disk (default: false)
}
{
"restored": 3,
"files": [
"d:/documents/projects/readme.md",
"d:/documents/projects/notes.md",
"d:/documents/projects/spec.md"
]
}
| Field | Type | Description |
|---|---|---|
restored |
number |
Number of files restored. |
files |
string[] |
Absolute paths of restored files. |
git ls-tree -r --name-only <commit> -- <path>). git ls-tree matches literal paths, not wildcards: pass a file or a directory (everything under it is restored). A wildcard pattern, or an unknown commit, restores nothing (restored: 0).git show.revert: {glob} to {shortCommit} — (the first 7 characters of commit), followed by the AI description or, on the template path, restored <n> files (plus (+ <m> other changes) when other files share the commit).Manage .gitignore entries.
curl -X POST http://localhost:1936/vcs/exclude \
-H "Content-Type: application/json" \
-d '{
"glob": "d:/documents/temp/**/*.tmp",
"root": "d:/documents",
"remove": false
}'
Body schema:
{
glob: string; // Absolute glob pattern to add or remove
root?: string; // Watch root (resolved from the glob if omitted)
remove?: boolean; // Remove the entry instead of adding (default: false)
}
{
"ok": true,
"gitignorePath": "d:/documents/temp/.gitignore",
"action": "added"
}
| Field | Type | Description |
|---|---|---|
ok |
boolean |
Success indicator. |
gitignorePath |
string |
Path to the .gitignore file that was modified. |
action |
string |
"added" or "removed". |
Uses the locality principle: the .gitignore entry is placed in the deepest directory before the first wildcard segment (the parent directory for a literal path). For example, d:/documents/temp/**/*.tmp results in a .gitignore at d:/documents/temp/ containing **/*.tmp.
Adding a pattern that is already present succeeds without writing (action: "added"). Removing deletes every line equal to the pattern; a missing .gitignore or pattern is 404 not_found. .gitignore only affects untracked files: a file git already tracks keeps being committed until it is removed from the index. With the default watch.respectGitignore: true, the watcher also stops reacting to the newly ignored files, so later changes to them are neither versioned nor re-embedded.
Check whether a file is excluded by .gitignore.
curl "http://localhost:1936/vcs/check-exclusion?path=d:/documents/temp/scratch.tmp"
| Param | Type | Description |
|---|---|---|
path |
string |
Required. Absolute file path to check. |
{
"excluded": true,
"rule": "**/*.tmp",
"source": "temp/.gitignore"
}
| Field | Type | Description |
|---|---|---|
excluded |
boolean |
Whether the file is ignored by git (git check-ignore). Any git check-ignore failure also reads as false. |
rule |
string? |
The matching .gitignore pattern (if excluded). |
source |
string? |
The .gitignore file containing the rule, as git reports it (relative to the root). |
The OpenClaw plugin exposes each endpoint as a tool. Tool errors read HTTP <status> <code>: <message>.
| Tool | Endpoint | Parameters |
|---|---|---|
watcher_vcs_status |
GET /vcs/status |
none |
watcher_vcs_history |
GET /vcs/history |
glob (required), since, until, limit |
watcher_vcs_show |
GET /vcs/show |
path, commit (both required) |
watcher_vcs_diff |
GET /vcs/diff |
glob, commit (both required), commitEnd |
watcher_vcs_revert |
POST /vcs/revert |
glob, commit (both required), existingOnly |
watcher_vcs_exclude |
POST /vcs/exclude |
glob (required), root, remove |
watcher_vcs_check |
GET /vcs/check-exclusion |
path (required) |
There are no VCS CLI commands.
When commitMessage.enabled is true (the default) and an API key resolves, the VCS subsystem generates one-line commit messages using an LLM.
{
"vcs": {
"commitMessage": {
"provider": "anthropic",
"model": "claude-haiku-4-0",
"apiKey": "${ANTHROPIC_API_KEY}"
}
}
}
Currently only the "anthropic" provider is supported (Anthropic Messages API). An unsupported provider logs a warning on each commit and falls back to the template.
The API key is resolved once at startup, per root, in order:
commitMessage.apiKey (with ${ENV_VAR} substitution).models.providers.<provider>.apiKey in ~/.openclaw/openclaw.json on the watcher host. The watcher reads the key and calls the provider directly; nothing is routed through the gateway.If neither yields a key, a warning is logged and every commit uses the template message.
AI generation is skipped for baseline commits and for retry attempts of a batch. If the LLM call fails (timeout, non-OK response, unsupported provider, empty reply), the commit uses the template message (watcher: batch <ISO timestamp> (<n> files)). The request sends the changed file paths and the staged diff (--stat plus patch) cut to 4000 characters, with a 15-second timeout; the reply is trimmed to 100 characters.
Revert commits start with revert: {glob} to {shortCommit} — . The rest is the AI description when one is generated, otherwise restored <n> files (see POST /vcs/revert).
Each root with a remote pushes after every commit that creates a commit (git push <remote> HEAD, i.e. the current branch to the same name on the remote). The URL is used directly; no git remote is configured in the repository.
{
"watch": {
"paths": [
{
"path": "d:/documents/**/*.md",
"vcs": {
"enabled": true,
"remote": "https://github.com/org/docs-backup.git",
"accessToken": "${DOCS_GIT_TOKEN}"
}
}
]
},
"vcs": {
"enabled": true,
"defaultAccessToken": "${GIT_ACCESS_TOKEN}"
}
}
remote: Git remote URL (per-root only).accessToken: Per-root token. Falls back to defaultAccessToken from the root-level vcs block.Authorization header (Basic, user x-access-token, which GitHub accepts) scoped to the remote URL, for that one push only. It is passed through the environment, never on the command line or in the URL, and configured credential helpers are bypassed so the push can never block on an interactive prompt. The token is ignored for non-HTTPS remotes (configured credential helpers stay in effect there, with interactive prompts disabled).pushErrors) are scrubbed of the token in raw, URL-encoded, and base64 forms.Push failures are non-blocking: the commit succeeds locally regardless. Errors are recorded in GET /vcs/status under pushErrors for the affected root. The remote is treated as a backup mirror, not a primary store.
The VCS subsystem periodically squashes old commits to prevent unbounded history growth. Retention defaults apply automatically when vcs.enabled is true, even if the retention block is omitted from config. The defaults are: 30 days max age, 100 versions max, daily midnight cron schedule.
Two retention limits work together, and the tighter constraint wins:
maxAgeDays (default: 30): commits older than this are eligible for squash.maxVersions (default: 100): only the most recent N commits are kept as individual commits.Everything older than the boundary becomes one parentless historical baseline commit with the boundary's tree.
Squash runs on a cron schedule configured via retention.squashCron (default: "0 0 * * *", daily at midnight). The scheduler is watcher-internal: it checks the expression once a minute against the host's local time, so no external cron daemon or task scheduler is needed.
The cron expression uses the 5-field format (minute hour day-of-month month day-of-week) with numbers only, and supports wildcards (*), lists (1,15), ranges (1-5), and steps (*/6). Day-of-week 0 and 7 both mean Sunday. Unlike standard cron, a restricted day-of-month and day-of-week must both match. An expression without exactly five fields never fires (no error is logged).
index.lock exists or an in-progress git operation is present (see Safety Guards).historical baseline commit from its tree (commit-tree).vcs.branch) to the new history, check it out and delete the temporary branch.All git operations during squash have explicit timeouts (30s standard, 120s for cherry-pick, 60s for push) to prevent permanent pipeline freezes from hung git processes.
Squash pauses the VcsManager commit pipeline before rewriting history. This drains any pending commits, then blocks new commits from executing during the squash. File changes that arrive during the squash are queued in the pending set and committed after resume. The resume always fires (via a finally block), even if the squash fails.
If a remote is configured, the squashed history is force-pushed. This is acceptable because the remote is a backup mirror: no other writers commit to it. A failed force push is logged; the local squash stands.
If index.lock is held by another git operation when the squash starts, the squash aborts cleanly (Squash aborted: index.lock exists, will retry next cycle) and runs again at the next cron match. No data is lost.
The squash rewrites history in the working tree (reset --hard, checkout -f), so it refuses to run when doing so could lose data or wedge the repo. Each refusal is logged and retried at the next cron match:
.git/sequencer, .git/rebase-merge, .git/rebase-apply, .git/CHERRY_PICK_HEAD, or .git/MERGE_HEAD exists, the squash refuses (error log). The watcher never clears this state itself; resolve it manually (e.g. git cherry-pick --quit, git rebase --abort, git merge --abort). The same condition is reported at startup.git status --porcelain --untracked-files=no reports anything, the squash refuses (warning log) rather than overwrite uncommitted content. Untracked files do not block.git cherry-pick --abort runs before returning to the configured branch (and the temporary branch and any leftover index.lock are removed), so a failure never leaves a half-finished cherry-pick sequence behind.If the service starts and a VCS root is on an unexpected branch (e.g., a leftover squash orphan branch from a crash), the VcsManager detects this during startup and recovers:
git rev-parse --abbrev-ref HEADbranch, force-updates the configured branch to the current HEADOrphan branches are not deleted: they serve as a recovery safety net. If recovery fails, the error is logged and the manager continues with the current state.
When a batch stages nothing (e.g., a file was touched but its content is identical), the pipeline detects it with git diff --cached --quiet and skips git commit. This is a no-op, not a failure: it counts as a success for the circuit breaker, and files are not re-queued. Detection does not parse git's output text, so it is unaffected by untracked files, unstaged changes outside the batch, status.showUntrackedFiles, or git's message wording.
Before staging, each batch is partitioned:
git add.git rm --cached --ignore-unmatch.A missing path never fails the batch. Paths are passed to git through stdin, so a large batch never hits the Windows command-line length limit.
The initial filesystem scan only reports files that exist, so deletions that happened while the service was stopped (or unlink events that were missed) are never observed as events. When the initial scan completes, each root lists tracked-but-deleted paths (git ls-files --deleted), keeps those inside the watch scope (watch.paths minus watch.ignored), and adds them to the baseline batch so the deletions are committed.
Only transient index.lock contention is retried within a commit attempt (4 attempts, exponential backoff). Any other error fails the attempt immediately with its real message.
Each commit covers at most maxBatchSize files; a flush with a larger backlog commits it in several chunks. When a batch fails, all of its files are re-queued (the pending set holds each path once, so it cannot grow beyond the distinct changed paths) and the throttle timer restarts so they are retried automatically. After maxConsecutiveFailures consecutive failures the per-root circuit breaker trips:
circuitBreakerCooldownMs (default 5 minutes) a single half-open commit attempt runs from a timer; no new file event is needed. Success resets the breaker; failure re-arms the cooldown.GET /vcs/status under breaker.Every git invocation made by the VCS subsystem passes -c core.longpaths=true, so paths longer than the Windows MAX_PATH limit work without any repo-local or machine-wide git configuration.
.gitignore vs watch.ignoredThese are separate settings:
.gitignore: controls what git tracks. Managed via POST /vcs/exclude.watch.ignored: controls what the watcher monitors at all (for both embedding and versioning). Set in config.With the default watch.respectGitignore: true, the watcher skips git-ignored files entirely, so a git-ignored file is neither embedded nor versioned. With respectGitignore: false, git-ignored files are embedded, but see the caution above about versioning them.
The VCS subsystem automatically adds these to every root's .gitignore:
.git/node_modules/.jeeves-watcher/.jeeves-metadata/These entries are re-added on every startup (missing ones are appended) and cannot be removed via the API.
When adding a .gitignore entry via POST /vcs/exclude, the entry is placed in the deepest appropriate directory. For example, excluding d:/documents/temp/**/*.log places **/*.log in d:/documents/temp/.gitignore, not at the root.
Git is a soft dependency. If git is not found on PATH at startup:
git not found on PATH — VCS disabled for this session and skips repository initialization and the state directory check.breaker.lastError in GET /vcs/status). Set vcs.enabled: false if git will not be installed.When VCS is enabled, the stateDir (default .jeeves-metadata) must not overlap with any watch path's root (same directory, parent or child). The watcher validates this at startup and fails to start if an overlap is detected. This prevents the watcher from versioning and indexing its own state files.
If a watch root contains child git repositories (nested .git/ directories), git does not add their files to the parent root's repository: changes inside a child repository are silently not versioned. The child .git/ directories themselves are excluded via the always-on .gitignore entries. Use separate watch roots, or keep child repositories outside VCS-enabled roots.
Symptom: Commits fail with index.lock errors.
Cause: Another git process (IDE, manual git command, squash) holds the lock, or a crashed process left a stale lock file behind.
Automatic recovery: The VCS subsystem handles lock contention automatically:
staleLockThresholdMs (default: 60 seconds), the lock is force-removed (Removed stale index.lock) and the commit proceeds.maxConsecutiveFailures (default: 5) consecutive commit failures, the breaker trips. Pending files are retained, and a single recovery attempt runs every circuitBreakerCooldownMs (default: 5 minutes) until one succeeds.No manual intervention is needed in most cases. If the circuit breaker trips, check breaker.lastError in GET /vcs/status and resolve the underlying issue. The next recovery attempt picks up the retained files automatically.
Symptom: Logs show Squash refused: in-progress git operation detected (.git/<marker>) or Squash refused: working tree has uncommitted changes to tracked files.
Cause: An abandoned cherry-pick, rebase, or merge in .git/, or tracked changes that the commit pipeline has not committed.
Resolution: For an in-progress operation, inspect the repo and clear it manually (git cherry-pick --quit, git rebase --abort, or git merge --abort). For uncommitted tracked changes, check GET /vcs/status for a tripped breaker; once commits succeed, the next squash cycle proceeds.
Symptom: First commit after enabling VCS is very large or slow.
Cause: All existing files in the watch root are staged for the initial commit.
Resolution: The maxBatchSize setting naturally chunks large initial commits. With the default of 1000, a root with 5000 files produces 5 sequential commits. Increase maxBatchSize if you prefer fewer, larger commits.
Symptom: GET /vcs/status shows entries in pushErrors.
Common causes:
Resolution: Push failures are non-blocking: local commits are safe. Fix the credential or network issue and the next commit will push successfully (lastPush updates; earlier pushErrors entries remain until restart). Check logs for detailed error messages.
Symptom: Log contains git not found on PATH — VCS disabled for this session; commits fail and the circuit breaker trips.
Resolution: Install git and ensure it is on the system PATH, then restart the watcher. On Windows, the installer typically adds git to PATH automatically. On Linux, install via your package manager (apt install git, yum install git, etc.).
Symptom: Log shows Repo is on unexpected branch — recovering to configured branch at startup.
Cause: The service crashed or was killed during a squash operation, leaving the repo on a temporary orphan branch.
Automatic recovery: The VcsManager detects the wrong branch at startup, force-updates the configured branch to the current HEAD, and checks out the configured branch. No manual intervention needed. The orphan branch is preserved as a safety net.
Symptom: Log shows timeout errors for git operations.
Cause: A git operation (add, commit, push, cherry-pick) took longer than its timeout.
Resolution: Standard operations time out at 30 seconds, cherry-pick at 120 seconds, push at 60 seconds. If timeouts occur regularly, investigate disk I/O, network connectivity (for push), or repository size (for cherry-pick during squash).