fix(git): the fetch timeout reaches the whole tree, a tick covers every session on its folder, and the rest of the review (review)

`execFile` signals the `git` parent only, so a fetch that times out can leave its http helper
still running, and on the interval that repeats every run with nobody watching. `runFetch` now
spawns `detached`, so the fetch and the helper it spawns share one process group, and the kill
is SIGTERM to the negative pid then SIGKILL `FETCH_KILL_ESCALATION_MS` later; the escalation is
not cancelled when the promise settles, because the caller is answered at the timeout while the
group is still dying. The output cap is kept, and the clone's slot queue is not needed here.

The tick is two passes: every enabled local session is collected by folder, a folder is due when
ANY session open on it is due, the folder is probed once for the group, and the run is published
to every session on it. A single pass decided by the order the sessions were read, so [long,
short] published only the short one and the 3600 s session's row never updated again while its
folder was fetched every 30 s.

`gitNonInteractiveEnv` writes BatchMode ssh as a default, not an override: `core.sshCommand` is
read inside the same slot and the same bound as the fetch it serves, and `fetchEnvFor` keeps the
repository's answer when it has one, because that is usually the setup that works on a machine
with several ssh setups. The `accept-new` host key it can write is stated next to the BatchMode
note: a prompt is stopped, a write is not.

The write claim is scoped to the Git panel and to the timer, with cloning and worktree management
named as the writes that happen when the user asked for them, and the zh keys sit with the group
of the UI surface their English string belongs to.

Tests: the process-group test proves the tree by group membership, because the helper is a
grandchild (`git fetch` runs `git remote-http`, which spawns `git-remote-http`) and because the
group is what the negative pid reaches, and it remembers each process once found instead of
re-scanning a group it had already emptied. The non-zero-exit test changed premises: `git fetch
--quiet` in a repository with no remote exits 0 on git 2.47.3, so resolving empty stdout there was
correct, and the case that has to reject is a remote pointing at a path that is not a repository.
The join test asserts after the run and does not await the request before the held run is
released, which would wait on the run the test is holding.
This commit is contained in:
Devvyn committed 2026-10-25 22:31:25 +08:00
1 parent ea4318947b
commit 3411d7f35a
15 files changed
+686 -165

No files matched your search

+11 -6
View File
@@ -11,8 +11,10 @@
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
* session changes or the window regains focus). The status route is read-only: it never changes the
* repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching
* happens only when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`,
* src/git-fetch.ts): the only git command Codeman runs that writes, and only on that click. Remote (SSH)
* happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`,
* src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel
* runs that writes, and the only one Codeman runs on a timer — cloning and worktree management write too,
* but only when the user asked for them at that moment. Remote (SSH)
* and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched.
*
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
@@ -269,10 +271,13 @@ Object.assign(CodemanApp.prototype, {
/**
* Fetch, on this click only: one `git fetch --quiet` per repository the panel shows
* (`POST /api/sessions/:id/git-fetch`). It is the only git command Codeman runs that writes, and it
* updates remote-tracking refs only, so an agent working in the same repository is not disturbed. One
* click means one fetch per repository: the button is busy and a second click is ignored while it runs.
* The panel then re-reads, so the "behind" number reflects what this fetch brought.
* (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the
* only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or
* touches the working tree, so an agent's own work is left alone — though a fetch does hold git's ref lock,
* so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One
* click means one fetch per repository: the button is busy and a second click is ignored while it runs, and
* a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the
* "behind" number reflects what this fetch brought.
*/
async fetchGitStatusNow() {
const sid = this.activeSessionId || null;
+28 -26
View File
@@ -520,12 +520,13 @@
'Connection tile: offline': '离线',
'Connection tile: queued': '已排队',
'Connection tile: retry': '重连中',
// App Settings → Bottom bar, translated as one group (the Git status rows,
// #543's two included). Keys are the trimmed label text, without the scope tag.
// App Settings → Bottom bar, translated as one group (the Git status rows, #543's two included, and the
// two Git fetch rows, in the order the page lists them). Keys are the trimmed label text, without the
// scope tag. The Session Options → Git strings are in that group's own block further down.
'Bottom bar': '底部栏',
'Git status': 'Git 状态',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取(这里唯一会写入的 git 命令)只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked) — runs when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。',
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
@@ -535,32 +536,15 @@
'Git status: git timeout': 'Git 状态:git 超时',
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Fetch git status': '拉取 Git 状态',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
// Git fetch on an interval (App Settings → Bottom bar, Session Options → Git, and the save toast in
// session-ui.js). 'Status' and 'Git' are not added: the first already has a key, the second stays a
// technical name, and the status row's text is written at runtime.
"Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。',
'Git fetch on an interval': 'Git 定时拉取',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository\'s own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前不会有任何定时运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Git fetch: how often': 'Git 定时拉取:多久一次',
'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.':
'没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话——这里,或在“会话选项 → Git”——才会被拉取,共用一个文件夹的会话由一次运行拉取。',
'Fetch on an interval': '按间隔拉取',
"Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on — here, or in App Settings → Bottom bar. The panel's Fetch button is always just a click.":
'拉取是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何定时运行。该窗口中的拉取按钮始终只是一次点击。',
'Follow the app setting': '跟随应用设置',
'Uses the app-wide switch and interval for this session. Switch it off to pick your own.':
'为此会话使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this session': '拉取此会话',
"Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.":
'即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。',
'How often': '多久一次',
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
'Git fetch settings saved': 'Git 定时拉取设置已保存',
'Fetch git status': '拉取 Git 状态',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
@@ -857,6 +841,24 @@
Memory: '内存',
GPUs: 'GPU',
// Session Options → Git (the tab's blurb, its group head, its hint and its three rows in index.html, plus
// the save toast session-ui.js raises). 'Status' is not a key here: that row's text is built at runtime
// by updateGitFetchStatus, and 'Git' stays a technical name.
"Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。',
'Fetch on an interval': '按间隔拉取',
"Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on — here, or in App Settings → Bottom bar. Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.":
'拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。',
'Follow the app setting': '跟随应用设置',
'Uses the app-wide switch and interval for this session. Switch it off to pick your own.':
'为此会话使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this session': '拉取此会话',
"Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.":
'即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。',
'How often': '多久一次',
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
'Git fetch settings saved': 'Git 定时拉取设置已保存',
// Cron / lifecycle / panels
'Cron Jobs': '定时任务',
'+ New Job': '+ 新建任务',
+3 -3
View File
@@ -1738,7 +1738,7 @@
<div class="set-group">
<div class="set-group-head"><h4>Fetch on an interval</h4></div>
<p class="set-group-hint">Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on &mdash; here, or in App Settings &rarr; Bottom bar. The panel's Fetch button is always just a click.</p>
<p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on &mdash; here, or in App Settings &rarr; Bottom bar. Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p>
<div class="set-group-body">
<!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the
run its folder got: several sessions on one folder share one run and read the same row. -->
@@ -2252,7 +2252,7 @@
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
<div class="set-row-text">
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the one git command here that writes — runs only when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.</span>
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` — the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked) — runs when you click Fetch in that window, or on the interval below if you turned it on. Not shown for Docker or remote sessions. Off by default.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
</div>
@@ -2280,7 +2280,7 @@
<div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically">
<div class="set-row-text">
<span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span>
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span>
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label>
</div>
+18 -14
View File
@@ -132,11 +132,13 @@ export function registerGitStatusRoutes(
// Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one
// `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot
// disagree about which repositories belong to the workspace. This is the only git command Codeman runs
// that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. It is not
// as harmless as a read though: a fetch holds git's ref lock, so an agent's own `git pull` in the same
// clone can collide with it, which is why the run goes through `fetchWorkspaceOnce` — a click while a tick
// is already fetching this folder joins that run instead of starting a second one. It can also run a
// disagree about which repositories belong to the workspace. This is the only git command the Git panel runs
// that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. Cloning and
// worktree management write too, but those happen when the user asked for them; this route is the one that
// can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so
// an agent's own `git pull` in the same clone can collide with it, which is why the run goes through
// `fetchWorkspaceOnce` — a click while a tick is already fetching this folder joins that run instead of
// starting a second one. It can also run a
// repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a
// fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn
// maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote
@@ -148,16 +150,18 @@ export function registerGitStatusRoutes(
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
const report = await fetchWorkspaceOnce(session.workingDir, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
});
const run = await fetchWorkspaceOnce(
session.workingDir,
{ git, ...limits, dockerWorkspaces: await dockerWorkspaces() },
'click'
);
// The response answers the click, and the run is also the session's current truth: what the panel's
// "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a
// second tab open on the same folder see the same run — including a click that joined a tick already
// going, where the row has to say who last fetched.
if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session);
return { success: true, data: report };
// second tab open on the same folder see the same run. The trigger recorded is the RUN's, not this
// request's: a click that joined a tick already going fetched nothing itself, so the row has to name the
// interval as what fetched.
if (session.setGitFetchStatus(gitFetchStatusFrom(run.report, run.trigger)))
persistAndBroadcastSession(ctx, session);
return { success: true, data: run.report };
});
}
+5 -4
View File
@@ -1491,10 +1491,11 @@ export const SettingsUpdateSchema = z
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel
* lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the
* server is what runs it: `git fetch` is the only git command Codeman runs that writes, and the only
* one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the
* automatic maintenance a fetch triggers, config commands such as `credential.helper`), so nothing runs
* on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A
* server is what runs it: `git fetch` is the only git command the Git panel runs that writes — cloning
* and worktree management write too, but only when the user asked for them at the moment they asked — and
* the only one that can run later by itself, on a timer, and the only one that can run a repository's own
* hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config
* commands such as `credential.helper`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A
* session can override both in Session Options → Git; `null` there clears the override, and sessions
* sharing one folder are fetched by one run.
*/
+3 -2
View File
@@ -410,8 +410,9 @@ export class WebServer extends EventEmitter {
// never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched.
probePath: (path) => probePathKind(path),
// Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the
// tick is already fetching joins it rather than starting a second `git fetch` in the same clone.
fetch: (cwd, opts) => fetchWorkspaceOnce(cwd, opts),
// tick is already fetching joins it rather than starting a second `git fetch` in the same clone — and the
// run that gets published is the one that ran, with its own trigger.
fetch: (cwd, opts, trigger) => fetchWorkspaceOnce(cwd, opts, trigger),
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setGitFetchStatus(status)) return;