diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index 48a3e79e86f..1765c568c2a 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -340,6 +340,7 @@ check cannot see it and the pages-dir half can. | Subagents | Background agent runs spawned from a session. A wave that is accepted but waiting shows why (`pages/chat/subagentQueuedReason.ts` renders the gate's `reason` from `subagent_queued`: a memory floor, macOS memory pressure or a paused adaptive cap; a bare count keeps the concurrency text). An agent reads its own memory footprint (its runtime, its sub-agents' runtime and the runs it started, numbers only) through `resource_status` | Chat activity viewer; rail badge; `resource_status` (agents only) | `pages/chat/ActivityViewer.tsx`, `pages/chat/subagentQueuedReason.ts` | `handlers/messaging.py`, `handlers/session_memory_scope.py` (the caller-scoped memory read) | `GET,POST /api/spawn`, `GET /api/spawn/session-memory` (agent-only, the attested caller's own scope), `POST /api/spawn/stop-all`, `GET /api/spawn/{agent_id}`, `DELETE /api/spawn/{agent_id}` (dismiss, including disk-only cards), `POST /api/spawn/{agent_id}/steer` | | Dynamic Dashboard | Template-based dashboards from one shared built-in catalog, for mates and root sessions alike, plus questions and one-shot tool approvals. The chat side panel's Dashboard view draws a header (title, help, permission mode), the Overview / Questions / Approvals segments with their counts, and notices; its Overview is the ROOT session's own Dynamic Dashboard (`CrewDynamicDashboard` with a session target), every field folded from that session's own work ledger and crew log. A dispatched or adopted session (`created_by` or a session-tree parent) gets no Overview and opens on Questions. Questions and Approvals stay host-rendered `AttentionCard`s. No status tiles are drawn above the composer. The + menu entry, a persisted Dashboard tab and the All Dashboards menu item are offered to every reader. The Crew chat's permanent Dashboard tab is the mate's own page. The gallery cards the sessions that have a card to show, by the same `canCarryCard` predicate the card frame fetches on (not private, not remote, no session-tree parent), with their saved summaries and decisions first; a dispatched session keeps its card because the card route serves it a derived board, while an adopted, incognito or remote session gets none and its questions and approvals still reach the gallery's Needs you inbox. The page's description names what an entry carries, since only a root session owns the dashboard PACKAGE. Optional automatic session cards update from session events under a shared model budget; unchanged layouts accept text-data updates. Only a root session (no `created_by`, no session-tree parent) gets one, and every number on it is folded from that session's crew log by `build_crew_main`, and the card frame renders those numbers in the dashboard's language from the phrases sent beside them (`crewMainPhrases.ts`): the model designs the layout and writes only `lede`/`you`/`notes`, and a card whose model part carries a digit is refused at publish. The pipeline conductor's work board is a DERIVED card instead: the panel publish route and the member-panel read assemble it from the typed board contract and the work fold with no model call, so it needs no budget and is not gated on the automatic-cards opt-in. The dashboard MCP tools write a mate's page or a root session's own page, never another's. Content publication time is separate from live host status. Isolated iframes cannot answer or approve. Native controls retain exact session/request routing and never change approval mode | Chat side panel + → **Dashboard** (root sessions get the Overview); Crew chat → its permanent **Dashboard** tab; Sessions three-dot menu → **All Dashboards** (`/session-dashboards`). **Automatic session cards** starts off and is enabled from its own card in Settings > Developer (a gateway-wide server setting) | `pages/members/MembersPageSidePanel.tsx`, `pages/chat/command-center/SessionDashboardsPage.tsx`, `pages/chat/command-center/CommandCenterPanel.tsx`, `pages/members/CrewDynamicDashboard.tsx`, `pages/chat/command-center/SessionStatusFrame.tsx`, `pages/chat/command-center/crewMainPhrases.ts`, `pages/chat/command-center/AutomaticCardSetting.tsx` (mounted by `pages/settings/DeveloperPanel.tsx`), `pages/chat/SidePanel.tsx` | `crew_main_contract.py`, `dashboard/card_lifecycle.py`, `dashboard/dynamic_cards.py`, `dashboard/handlers/agent_panel.py`, `dashboard/handlers/member_dashboard.py`, `dashboard_templates/instance.py`, `pipeline_board_contract.py`, `dashboard_card_pages/pipeline_board.html`; existing artifact, session, question, approval and workflow handlers | `GET /api/chat/slots/{slot}/dashboard`, `GET /api/members/{slug}/dashboard`, `GET /api/chat/slots/{slot}/dashboard-card`, `GET /api/members/{slug}/panel`, `POST /api/agent-panel/publish`, `GET /api/chat/slots/{slot}/summary`, `GET /api/sessions/{id}/crew-log/projection/work`, `GET /api/workflows/runs`, `GET /api/ask-question/pending`, `GET /api/approvals`, `POST /api/chat/slots/{slot}/approve`, `POST /api/approvals/{id}/{action}`, `PATCH /api/config/kirocrew` | | Worktrees | Create a git worktree for a follow-up session | Chat follow-up card → new worktree | `pages/ChatPage.tsx`, `pages/useChatPageSlotSwitches.ts`, `pages/chat/page/followupCardActions.ts` | `handlers/worktree.py` | `POST /api/worktree/create` | +| Branch switcher | Switch the project's local branch, check out a remote-only branch as a tracking branch, or create a branch at the current commit, from one searchable picker; off while the chat's response runs | Chat side panel → **Git** → the branch name in the panel header; or the branch segment of the project chip under the chat box (opens upward) | `components/BranchSwitcher.tsx`, `components/GitPanel.tsx`, `components/ChatInput.tsx`, `pages/chat/ActivityViewer.tsx` | `handlers/git_branches.py` | `GET /api/project/git/branches`, `POST /api/project/git/switch` | | Crew board | One conductor's work items as a board a human can scan — items waiting on a decision lifted into a band at the top, everything still open next, finished items collapsed behind an expander; each row carries the worker's `status` and the conductor's `state` as separate chips. An orphaned item (its conductor's session gone while the item is open) offers **Stop current turn** (the delegate cancels the turn that worker is running and leaves its session open, so the label promises only what the server delivers), with one dim line stating either why it cannot run or what stopping keeps; **Take over** renders no control at all while the server reports it unavailable, which is every gateway until a server-side primitive for it exists. Polls every 10 s; no payload carries a worker session key, so actions are keyed by item and resolved server-side | `/crew-board?conductor=`, reached from a session's overflow menu (**Crew board**, shown only for a session that owns a work ledger) | `pages/CrewBoardPage.tsx`, `pages/crewBoardRows.ts`, `components/CrewBoardMenuItem.tsx` | `handlers/work_ledger_board.py` | `GET /api/crew-board`, `POST /api/crew-board/action` | ## Settings diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index ab08fa5af7f..2ae9004a6de 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -3848,6 +3848,8 @@ transaction lease remains unchanged. **Status/System**: `/api/status`, `/api/system` (live metrics, 1s refresh, static fields cached), `/api/stream` (SSE), `/api/ws` (WebSocket — single multiplexed connection replacing SSE + polling for React SPA) **Project git**: GET `/api/project/git?path=` (checked-out branch label for the activity panel). `path` is matched against the gateway's own known project directories (every live chat slot's `project` plus the recorded recent-projects list) using pure string normalisation (`expanduser` + `normpath`), with no filesystem access on the untrusted value; an unrecognised directory is refused with 403 plus an SEL audit record, and only the matched server-held string is stat'd. Response is `{"repo": false}`, `{"repo": true, "repoRoot", "isRepoRoot", "branch"}`, or the detached form `{"repo": true, "repoRoot", "isRepoRoot", "detached": true, "head"}`, plus `path` on the envelope. `isRepoRoot` is true only when the project directory IS the work-tree root, compared on the unredacted realpaths; a project nested inside a repository rooted higher up answers false, which is what the follow-up card reads to withhold its worktree action (`POST /api/worktree/create` refuses a toplevel above the session's project unless another session is scoped to that toplevel, and the card does not offer branching a whole enclosing repository from one folder of it). No `git` subprocess is spawned; repository discovery is the dashboard's one in-process walk, `git_repo_touches.find_git_root` (shared with the Git tab's repository list below: a linked worktree's `.git` file counts, `GIT_CEILING_DIRECTORIES` stops it as it stops git -- so a project under a ceiling reads as not a repository here exactly as the status and log routes' `git rev-parse --git-dir` reports it -- and the depth is bounded by `_GIT_ROOT_WALK_LIMIT`), and the branch comes from reading `.git/HEAD` directly (a `git` invocation parses repo-local config, which can carry `[include] path = ` and read an arbitrary file, so reading HEAD ourselves removes that class), and a linked worktree's `.git` pointer file (`gitdir: `) is followed to the real HEAD. Both metadata reads go through `hooks.safe_read_prefix` (realpath canonicalisation, sensitive-target rejection, `O_NOFOLLOW`, bounded read). `branch` and `head` pass through `redact` before being returned; `path` and `repoRoot` use the same redactor with one narrow path-specific exception, applied only on Darwin, for the fixed-shape `/private/var/folders/<2>/<30>/T` temp prefix, whose slash-inclusive entropy otherwise looks like a bare credential. The split sits at the END of the OS-generated `<2>/<30>` id: only that id is withheld from the scan, while the fixed literal `/T` and the complete user-controlled suffix still pass through `redact` together — handing `/T` to the redactor is what keeps the 40-byte credential window that would start on that `/` in scope. The SEL audit keeps the real path. Every failure mode degrades to "no branch" rather than erroring, since the only consumer is a decorative label. A detached HEAD gets a fixed 7-char prefix, not git's dynamic abbreviation. GET `/api/project/git/status?path=` and GET `/api/project/git/log?path=` use the same known-project boundary, widened by `_slot_git_repo_snapshot` to the git roots each live slot's tool calls touched (see **Project git repos** below). The status route first checks `os.path.isdir` and repeats that check after any subprocess failure; a missing or non-directory project returns `200 {"repo": false, "files": []}`. It performs no in-process inspection of `.git` or anything `.git` points to. Instead it runs sandboxed Git with `LC_ALL=C` and `LANGUAGE=C`: a bounded `git rev-parse --git-dir` probe owns repository discovery. A nonzero probe whose bounded stderr contains Git's `not a git repository` verdict is confirmed absence and returns `200 {"repo": false, "files": []}`. Symlink markers, gitdir files, parent-repository discovery, ceiling and cross-filesystem rules therefore follow Git. Any other probe failure, including sandbox refusal, dubious ownership, permission failure, timeout, kill, or missing executable, returns 503 with `code: "git_status_unavailable"` unless the project directory disappeared. After a successful probe, a nonzero `git branch --show-current` (which validates corrupt symbolic HEAD while accepting detached and unborn states), a failed or empty `git rev-parse --show-toplevel`, or a nonzero `git status` is also 503. **The filter-driver refusal is 503 too, not an empty listing, and it carries its OWN code.** A repository whose own config names a `filter.*.{clean,smudge,process}` driver -- anything `git lfs install --local` touched -- is refused because `git status` re-hashes modified files through a `clean` driver on every 5 s poll, and the probe refuses under its own separate cause when it cannot prove a driver absent. The match is deliberately WIDER than execution: `smudge` fires on checkout rather than on the status re-hash, a driver that no `.gitattributes` path maps to never runs at all, and the probe-failure branch refuses without knowing a driver exists. Refusing all three is right, since none of them can be proven safe -- but the user-facing message therefore says the config DECLARES a driver and that policy refuses the check, and never claims a program ran. Spelling that refusal `200 {"repo": true, "files": []}` makes it byte-identical to a genuinely clean repository, so the panel draws its green clean badge over a working tree whose status was never read; the panel's one job is surfacing uncommitted changes, and a confident "none" is worse there than no answer at all. The code is `git_status_filter_refused`, distinct from the `git_status_unavailable` its four siblings use, because this condition is not an outage: the cause is knowable, and for the `declared` cause it is permanent for that repository with no retry clearing it while the config stands (the `unreadable` cause is the one that can clear on its own, which is why only the declared copy promises permanence). One code for both would tell an LFS user their repository is broken on every poll forever. The panel renders it as a SINGLE `ErrorNotice` whose message names the cause, rather than the two generic notices the outage path shows: both routes refuse together because the cause is repo-level, so two boxes would report one condition twice. The refusal notice is TITLED and the outage notice is deliberately untitled -- titling both put two red boxes opening on the same word beside each other while they carry opposite retry semantics, so the difference between permanent and transient was a body-read away; titled against untitled separates them at a glance, and the outage title was restating its own first clause anyway. The refusal title names the OUTCOME ("Kiro can't check this repository for changes") rather than the mechanism: "status checks are off" collided with the outage string's "couldn't read the repository status", so one panel used the word status for two conditions of different severity. A coded status refusal reaching the non-coalesced branch renders that same title and localized copy, because the backend's English sentence under a localized heading is mixed-language copy in the other twelve catalogs. The header REFRESH control is also inert while BOTH routes refuse, and stays live for an outage: the refusal copy promises that retrying will not help, a re-fetch returns the same 503 every time, and a live control over that sentence contradicts it. It is disabled rather than hidden so the header does not reflow, and the glyph itself switches to the slashed variant, because dimming plus a hover tooltip did not tell a reader the control was dead. It is keyed on both routes rather than on the coalescing predicate deliberately: the refusal is permanent only while the repository config stands, and the notice's own agent hand-off invites changing it in the same window. The status query then recovers on its own 5 s interval while the log query -- no interval, 30 s `staleTime`, and a branch-change effect that cannot fire on the FIRST successful status because it needs a previous marker -- still holds its cached refusal. Keying the inert state on the coalescing predicate would therefore disable the one control able to clear that, leaving a notice claiming history cannot be shown while the changes list renders beneath it. The panel also refetches the log automatically as soon as the status route comes back CLEAN (not merely stops refusing: a status route failing for its own reason proves nothing about the config). The 503 body carries a `cause` discriminator alongside the code -- `declared` when repo config names a driver, `unreadable` when the probe could not prove one absent -- and the panel renders one localized sentence per cause. One code, because it is ONE condition (refused by policy) with two causes; a disjunction in the copy ("declares a driver, or could not be read") made the common case, an LFS repository on every visit, read a question the guard had already answered, and a reader could only partly follow it. Naming a driver as fact in the probe-failure branch would still be wrong, which is why that branch answers its own cause rather than borrowing this one. It stays an `ErrorNotice` with the `askAgent` hand-off because the string originates in a `{error, code}` body, which is what the `errors-use-error-notice` rule scopes on, and because a repo-supplied filter driver is precisely what the agent can explain or change -- routing it around that surface would strip the one affordance that helps here. HEAD is still validated BEFORE the filter guard, which `test_status_checks_corrupt_head_before_filter_refusal` asserts by spying that the guard is never reached, and the two codes now witness the ordering as well. A successful listing is capped at 500 entries and carries `truncated: true` when entries were dropped; the panel renders that as a capped count (`500+ uncommitted`) plus a `showing first 500` note on the Changes header, because without it the cap read as the total and an undercount was presented as a count. `/api/project/git/log` shares the status route's discovery boundary through the same `_probe_git_dir` probe (`LC_ALL=C`, `LANGUAGE=C`, bounded stderr) and the same verdict predicate, `_is_not_a_repo_verdict`, which is the one documented classification contract for both routes: a nonzero probe carrying Git's `not a git repository` verdict, or a project directory that disappeared, is `200 {"repo": false, "commits": []}`; every other probe failure (sandbox refusal, spawn failure, dubious ownership, permission failure, timeout, kill, corrupt repository metadata) is 503 with `code: "git_log_unavailable"`. The log route also applies the same filter-driver refusal and answers it with 503 `code: "git_log_filter_refused"`, since an empty commit list is what a repository with no commits legitimately returns. A nonzero `git log` AFTER a successful probe deliberately stays `200 {"repo": true, "commits": []}`: an unborn repository exits 128 there, so that path is genuinely ambiguous and separating it would need a stderr match of its own. The panel renders `git_log_unavailable` as the log-side outage notice -- untitled, with the localized `log_failed` copy rather than the backend's English sentence, mirroring `git_status_unavailable` -- and suppresses the clean-repository empty state while the log route has failed, because "no commits to display" beside a notice saying history could not be read presents an outage as an empty history. The Git panel treats only `repo: false` without a simultaneous status error as a terminal no-repository state: it replaces the loading label, hides the clean badge and clean-repository empty copy, and tells the user to click the folder name below the message box. The other two `['git-status', …]` consumers now receive 503s on operational failure where they got 200s, and each was checked rather than assumed. `FileBrowserRail` renders an inline `ErrorNotice` with the agent hand-off on an error in All mode, and deliberately not in Changed mode because the Pierre tree owns the same query failure there. `PierreWorkspaceTreeImpl` treats a status error as terminal for a Changed-mode load and renders that notice with a structured report. Both of those notices RECOGNISE the refusal codes and render the panel's own refusal copy rather than their generic failed copy, because the refusal is permanent for an LFS-configured repository: generic copy there would say "failed" on every 5 s poll, forever, with no cause and no statement that retrying cannot help — the exact outage spelling this refusal code exists to end. The recognition lives once in `website/src/utils/gitStatusError.ts` (`gitErrorCode`, `isGitFilterRefusal`, `gitFilterRefusalCause`, `gitFilterRefusalCopyKey`) and all three components read it, so one condition has one wording rather than three to keep in step. Neither of those two carries the panel's TITLE, deliberately: `ErrorNotice`'s `inline` variant lays title and message out as flex siblings, so at the rail's 300-520px and the tree's width the title wraps into a stack of two-word fragments beside a narrow column of message — visible in `capture-git-filter-refusal-surfaces.mjs`'s frames, which is what settled it. The title exists in the panel to separate a refusal from a coexisting outage notice, and neither of these surfaces renders a second notice beside this one. The status outage string also has a variant used when the log route failed too, which names the half it covers ("Couldn't read the changes list."): the default wording promises a commit list that is still on screen, so beside a notice saying history cannot be shown the two boxes first contradicted each other and then, once the clause was merely dropped, read as one failure reported twice. Naming each half is what separates them. Neither of the two reads non-200 as "not a repository"; both read `repo` only from a 200 body, so a refusal reaches them as absent data rather than as a negative answer. **Project git repos**: GET `/api/project/git/repos?slot=` lists the repositories the chat side panel's Git tab shows for one session, because most sessions start in the shared workspace (not a repository) and work in repositories elsewhere. `kiro_crew.dashboard.git_repo_touches` notices them from the session's own tool calls in `chat_runner` (both `tool_call` and its `tool_call_update` refinement): an edit call's target paths (`edit_target_strings`: the params walk the edit gates run, `target_paths`, plus patch headers, with no path expansion), a shell call's `working_dir`/`cwd`, and a leading `cd `/`pushd ` or `git -C ` in its command (absolute or `~` only; a relative edit path resolves against the session's project and is dropped without one). Each candidate walks up to the nearest `.git` entry through `find_git_root`, the same walk the branch label above uses (a linked worktree's `.git` file counts; `GIT_CEILING_DIRECTORIES` stops the walk as it stops git), and the real root is kept on the slot (`_ChatSlot._git_repos`, oldest to newest, capped at `MAX_SLOT_GIT_REPOS`) unless it is the home directory, the filesystem root or a sensitive path. The walk never runs on the runner's event path: `note_tool_call` snapshots bounded raw candidate strings and a per-call `truncated` bit on the loop, retaining them only until `finish_tool_call` confirms successful execution. Refused, failed, cancelled and unfinished calls discard their candidates and loss bit; turn cleanup also clears proposal-eviction uncertainty. Executed calls queue their candidates on the slot (`_git_repo_walk_pending`, distinct, a retouched path moved to the newest end, bounded by `_MAX_PENDING_CANDIDATES_PER_SLOT` with the oldest dropped first) and returns; one walker task per slot (`_git_repo_walk_task`, held in `state._background_tasks` like every other dashboard fire-and-forget task) resolves the whole pending list in a worker thread and loops until nothing is queued, so a `stat` that hangs on a dead mount holds that task and never the turn's stream, the runner's next event is handled at once, calls land in call order (two concurrent walks could list an older touch as the newest), and a burst of calls is one thread hop. A slot closed mid-walk receives its roots on an object nothing reads; a loop shut down mid-walk cancels the task and asyncio drops the thread's late result. No broadcast follows a walk: the Git tab polls the listing (`refetchInterval` 10 s), so a root recorded after the turn's event shows on the next poll. Each listed repository is a collapsible section whose status query polls every 5 s only while it is open, and only the `MAX_POLLING_SECTIONS` (3) most recently opened sections poll at once: repositories the agent edited open by themselves, so without the cap every dirty repository would run its git subprocesses on every interval. A section the user opens becomes the newest and always polls; an open section past the cap fetched once and refreshes on window focus and on its Refresh control. The list is in memory only; the first listing after a restore folds in the roots the transcript's `file_changes` rows resolve to (`_git_repos_seeded`), so edited repositories come back while a repository the agent only ran commands in reappears on its next touch. The response is `{repos: [{path, source}], omitted: , listed: , incomplete: }`: `incomplete` is always present and reflects the sticky, slot-local `_git_repos_incomplete` flag, or a row refused for redaction in this response. Candidate-count, edit-traversal, patch-parser, command-directory, command-text/argv, refinement-merge and path-length cuts carry their loss through the call's snapshot, including an empty snapshot, and publish it only on execution. Evicting executed paths from the walker backlog also sets the flag. Pending-proposal eviction retains only one turn-local uncertainty bit, not an unbounded tombstone list: a later successful result with no retained proposal conservatively marks the list incomplete; a refusal or turn cleanup does not. Evicting the oldest terminal receipt marks nothing incomplete: the refinement a receipt waits for is read from the same wire update as its result, so it has arrived long before 64 later results are recorded, and most receipts belong to calls that name no path. Worker-thread expansion/canonicalization path bounds and the ancestor-walk depth budget return loss alongside roots, as do transcript-seed count/path bounds, and the listing folds those flags back onto the slot even when no roots survived. Home, filesystem-root, sensitive, unanchored and not-a-repository exclusions do not themselves mean truncation. The flag is not a missing-repository count and never inflates `omitted`; the Git tab renders it once per snapshot as a localized muted informational line, even on an empty list: "Some repositories the agent worked in may not be listed. Each one appears here once the agent works in it again." No path expansion or filesystem access is added to loop-side bookkeeping. `omitted` is always present and counts distinct repositories this session worked in that the list leaves out because only the `MAX_SLOT_GIT_REPOS` most recent are kept, including transcript seeding: evicted roots are remembered by path (`_ChatSlot._git_repos_evicted`, capped at `MAX_SLOT_EVICTED_GIT_REPOS`) and a root touched again leaves that list, so revisits never inflate it; older evicted roots are kept only as a count (`_git_repos_evicted_overflow`), so past that bound the number is an upper estimate (a forgotten root that returns can be counted twice). Deleted repositories filtered from the response do not count. `listed` counts the agent-touched repositories the response actually lists (the project's own included only when the agent worked in it). The Git tab's capacity notice reads its numbers from `listed` and `omitted` ("The agent worked in repositories; the most recent are listed."), so an untouched project row or a deleted repository never skews them; the notice is a localized informational line below the list, shown when the count is positive, and names no action because the server keeps nothing it could reveal. When the list is non-empty the tab opens with a one-line caption saying the rows are the repositories the agent worked in during this chat, with a second sentence appended only when the chat's own project is among them to say the project folder is listed first, so the Project tag's meaning is visible without its tooltip; the empty state says how the list fills (the agent editing files or running commands in a repository); the listing-failed notice carries a Retry that refetches beside the agent hand-off. The session's project comes first when it sits in a repository (`source: "project"`, `path` the project directory itself), then every touched root that still has a `.git`, most recent first (`source: "agent"`), each `path` accepted verbatim by the status and log routes. A row whose path the credential redactor (`_redact_project_path`) would mask is left out of `repos` and of `listed` and sets `incomplete` for that response: the masked string is not a path those routes accept, so the row could only show an error, and the unmasked path never reaches the client. The touched roots join those routes' allow-list (and only theirs: the tree, path completion and branch label keep the project-only boundary) because they are server-derived from the session's own tool calls, never from a request. An app token reads only a slot it owns on its own session and transcript (`deny_app_slot_session_access`, audited when the slot exists); any other slot is the same 404 as a missing one, and for an app token the status and log routes admit only the touched roots of the slots it owns (`app_owns_slot_session`). +**Project git branches**: GET `/api/project/git/branches?path=` and POST `/api/project/git/switch` with `{path, branch, create?, track?}` back the Git panel header's branch switcher (`website/src/components/BranchSwitcher.tsx`, read only while the picker is open). They live in `dashboard/handlers/git_branches.py`, are registered behind `dashboard/remote_chat_guard.py` like the status and log routes (409 `chat_runs_remotely` for a chat named by `?slot=` that runs on a session environment), and reuse the boundary `POST /api/worktree/create` draws for the one other repository-changing route: `path` must name or sit inside a live chat slot's project and resolves to the nearest one (the project it names, else the deepest one it sits inside, `_closest_allowed_root`, so a repository nested in another chat's repository is listed rather than its parent); the switch, which writes, accepts only a path that IS a chat project (`_exact_allowed_root`), so a request for a nested repository that stopped being any chat's project while it was in flight is refused with 403 `unknown_project_dir` instead of resolving to the parent and checking it out, and so must the git toplevel it resolves to (`repo_root_outside_project` 403 otherwise, which also refuses a `core.worktree` pointed outside the grant), and git runs through `worktree._run_git` (strict sandbox, argv list, `core.hooksPath=`, `core.fsmonitor=false`) with `LC_ALL=C` and `LANGUAGE=C` set on the scrubbed environment (`_run_git(..., c_locale=True)`), because the failure codes below are read from git's English diagnostics. Only git's own `not a git repository` verdict on the toplevel probe is absence (404 `not_a_repository`); any other failed probe, dubious ownership included, is 503 `git_branches_unavailable`. The listing is dashboard-caller gated and returns `{repo, current, detached?, head?, local, remote, truncated?, switchBlocked?}`: local branches by `for-each-ref --sort=-committerdate`, capped at 200 per section with `truncated`, each row carries `{name, date, author, subject, switchable}` plus `current` and optional `ahead`/`behind` counts for local branches; remote-tracking branches minus `/HEAD` and minus any whose short name already has a local branch. Every string is redacted, and a name the redactor alters is returned with `switchable: false`, since echoing a redacted name back could never resolve. A repository whose config declares a checkout filter driver (`_checkout_filter`) still lists, with `switchBlocked: "filter"`, so the picker can say why its rows are inert. The switch is OWNER-gated (`require_owner_dashboard_request`, as `/api/file-write` is: it rewrites working-tree files), serialized per repository on `worktree._repo_lock`, refuses with 409 `git_switch_session_busy` (under that lock, before any git runs) while any chat slot whose project is the repository root, a folder inside it, or a folder containing it has a turn running or sub-agents pending (a running slot is matched on every folder its turn works in as well as its current project -- the project at the turn's entry, the project its session was allocated for, the folder that session reports (`client.cwd`, recorded as `_ChatSlot._checkout_turn_projects`) and the folder the live session for its key reports now -- because a project picked mid-turn, the `set_project` tool and a member rebinding all write `project` while the turn's session keeps its folder, and a session kept across a deferred reset or resumed from a stored folder runs where no `project` value names; from the moment a turn starts allocating its session until that session reports its folder the turn is unplaced, `_checkout_turn_unplaced`, and counts as busy in every repository, as does a running slot whose session registry cannot be read or whose provider reports no folder), or while a workflow step's session runs in the repository or has not reported its folder yet (workflow steps open their own sessions outside chat slots and the sub-agent manager, so each step -- the cold per-call path, a pooled worker's cold start, reuse reset and turn, and the pool's unpooled leg -- registers through `repo_checkout_guard.tracked_work`: unplaced, and waiting out every checkout in flight, before it claims its session, then placed on the folder that session reports, with the project its prompt is built against recorded beside it since a reused named session can run elsewhere, and waiting out a checkout of either; a session that reports no folder leaves the step unplaced; the step's record goes when the step ends, and a turn it abandons -- cancelled, failed, ended by the session layer, cut off at the tool-call ceiling -- stays counted through its prompt's own record (below), so the step's session, a named one included, keeps its conversation), or while any prompt is in flight on any agent process (every harness sends a turn as an ACP request through `AcpClient._send_request` or one of `AcpRuntime`'s three request writers, and each records it with `repo_checkout_guard.prompt_sent` just before the write, after waiting out a checkout of its session's folder (`before_prompt`, which does not suspend when no checkout is in flight), and drops it again with `prompt_unsent` when no byte of the frame reached the pipe (its caller cancelled while it waited for the shared write lock, a dead runtime refused the write, or the lock wait stalled), since nothing will ever answer it, until the backend's own answer to that request id is read (`prompt_answered`: only an int equal to the issued id) or the runtime confirms the whole process tree exited (`prompt_process_gone`); a cancel, a timeout, a synthetic end of turn or a caller that stops reading leaves the record, mid-turn steers count too and a steer answered `startedNewTurn` stays counted until the tree is gone, a `_kiro/checkpoint/` request counts since it restores files, and a prompt whose session reports no folder counts in every repository -- so a channel turn, a monitor wake, a cron or heartbeat turn and an app are seen whatever drives them; the knowledge pool's legacy `CCWorker`, the one worker with no ACP, records its prompts the same way), or while any running sub-agent works in the repository through its own `cwd`, the folder its session started in (`_session_cwd`), an ancestor run's, or its root chat's project (a failed sub-agent probe, an unreadable run registry, a dismissed ancestor, or a run with no `cwd` whose session has not recorded where it started counts as busy, since the allocator places such a run: a member's workspace, a resumed conversation's stored folder, the pool's or a per-session work folder), because a checkout rewrites files under every such session and the picker's own disabled-while-running guard only sees its own chat, and refuses the filter case with 409 `git_switch_filter_refused`. The busy check counts each session only in its own folder (plus, for a workflow step, the step's project while the step runs): edits an agent makes in another repository by path, including through a reused named workflow session from another repository, are not counted, and git's own refusal to overwrite uncommitted changes is what still protects that work. Neither turn nor sub-agent admission takes that lock, so the switch also reserves the repository in `kiro_crew/repo_checkout_guard.py` (the registry, below the dashboard so the engines that register with it import no dashboard code; `dashboard/repo_checkout_guard.py` keeps the readers of slot and sub-agent folders) synchronously before the busy check awaits. Every `_run_chat` waits at its entry (its first await, before any prompt expansion reads a file) while a reservation covers its slot's project, waits again, once marked unplaced and before it captures its binding and claims its session, while any reservation is held anywhere, and once the claimed session reports its folder waits while a reservation covers that folder, and every sub-agent run awaits the manager's `pre_run_gate` (installed by `DashboardState`) just before it executes, waiting while a reservation covers any of those same folders, or while any reservation is held when they cannot be known; the run's `_session_cwd` is cleared just before the gate, so a continuation is not judged by its previous execution's folder. Work already published -- a turn's task on its slot, a run counted as its parent's pending work -- is seen by the busy check; work that starts later waits for the checkout. The reservation is released by the checkout worker's own completion, and a cancelled switch request holds the lock until git exits, so neither can be dropped while files are being rewritten; a second switch arriving while a reservation is held gets the same 409. The wait has no deadline (every git call the checkout makes is bounded, so the worker always finishes); it logs a warning every 120 s. An existing name must be unambiguous as a ref (no leading `-`, no `@{`, no whitespace) and resolve under `refs/heads/`; `git symbolic-ref --quiet HEAD` identifies an already-checked-out branch by its full `refs/heads/` name for a 200 no-op, otherwise it runs `git switch --no-recurse-submodules --no-overwrite-ignore --no-guess -- `. Every successful switch returns `{ok, branch}`; `create` requires the follow-up branch grammar and runs `git switch --no-recurse-submodules --no-overwrite-ignore --no-track -c`; `track` must resolve under `refs/remotes/` and runs `git switch --no-recurse-submodules --no-overwrite-ignore -c --track refs/remotes/`. `--no-recurse-submodules` overrides a repository's `submodule.recurse`, so a submodule's own configured filter drivers (which `_checkout_filter` on the superproject cannot see) are never reached; a remote row is `switchable` only when its short name also satisfies the follow-up branch grammar the `track` path enforces. git's own refusal to overwrite uncommitted or untracked changes is relied on, never bypassed, and `--no-overwrite-ignore` extends it to ignored local files the target branch tracks (git's default replaces those silently); both are reported as 409 `git_switch_dirty`, with `files` naming up to five of the paths git lists as in the way (each redacted, then clipped) and `files_more` counting the rest, so the refusal can say which files block it; other coded failures are `git_branch_exists`, `git_branch_in_worktree`, `git_operation_in_progress`, `git_branch_not_found` and `invalid_branch`, with uncoded ones as 400 `git_switch_failed` carrying git's first line as `detail`, redacted whole before it is clipped to 300 characters. The picker renders each code as localized copy, and after a switch invalidates the status, log, branch, project-tree and project-git (composer chip) queries. + **Project tree**: GET `/api/project/tree?path=` (workspace file listing for the Files tab's Pierre tree, and for a chat side-panel `folder` tab whose path IS the chat's project root — `FolderPanel` renders the same `PierreWorkspaceTree` there so descending expands in place instead of re-targeting the tab, and falls back to its one-level `browseFiles` listing for every other directory and whenever this endpoint does not answer; while the payload is `truncated` the tab keeps the tree but sends its search box to the recursive server search (`/api/file-search`), whose matches stand in for the tree until the query is cleared (the tree stays mounted, hidden, so the folders the user expanded are still open when it returns), because the tree's own filter can only find what the capped tree lists; the matches are the server's merged with the tree's own paths that contain the query (case-insensitive, de-duplicated, the server's first), because the server search runs under its own scan budget and can miss a file the tree is already showing; a line above them (`search_beyond_tree`) says results can include files past the 10,000-item limit -- not that it covers the whole workspace, which that budget cannot promise -- since on a whole tree the same box narrows the rows in place, and the tab's Refresh re-runs that search too, as the listing body's Refresh does, because it is the retry beside a failed search -- because a directory path chip can name any directory on the gateway while this allow-list deliberately does not). File tabs start with their optional project-tree rail hidden so the document receives the full panel width; the toolbar toggle and persisted `mc-files-rail-open` preference keep it available on demand. Same known-project allow-list, sensitive-path refusal, and SEL auditing as `/api/project/git`. Inside a git repository the listing is `git ls-files -z --cached --others --exclude-standard` with `cwd=` (tracked + untracked, `.gitignore` honored; a project dir that is a repo subdirectory lists only its own subtree); outside one -- or when that listing fails (a nonzero exit: its 8 MiB output cap, its 15 s timeout, or no sandbox backend) -- it is ONE bounded breadth-first pass (`_project_tree_walk`, `os.scandir` per folder, never following a link) that skips tool and heavy directories (`_PROJECT_TREE_SKIP_DIRS`), walks every other dot-directory (e.g. `.worktrees`), drops every entry, directory or file, that is dot-named or sits under a dot-path (judged on the read folder's real path, ancestors above the project root included) when `is_sensitive_resolved_path` fences its real path (`_project_tree_fence`), and stops reading after `_PROJECT_TREE_SCAN_LIMIT` (20 × the row cap, 200,000) directory entries, so its cost is a function of that number rather than of the size of the tree (about half a microsecond an entry). The budget is shared so one large folder cannot starve its siblings, or their subfolders, wherever it sorts, without cutting a tree that fits: each folder of a depth, and the depths below as one more claimant, is guaranteed a floor of half the even split the depth starts with, and the folders are read in order, each up to everything the claimants after it do not need for their floors -- capped at the row cap, more entries than one folder could show -- and never less than an even split of what is left. So a tree of at most about half the scan limit whose folders each hold no more than the row cap is read whole, wherever its large folder sorts. A folder holding more than its share is cut there and named in `truncatedDirectories`; the entries it did not read, subfolders included, are unknown. Telling a full folder from a cut one costs one look-ahead entry, drawn from the same budget, so no listing reads more than the limit. A folder whose row falls past the cap is never read, and a read cut by the budget is never judged hidden-only. A subfolder is queued when its parent is listed and read one level later, so each queued folder carries the `(st_dev, st_ino)` its parent's listing recorded, and is read only if it is still that folder (`_project_tree_scandir`): on POSIX the name is opened `O_DIRECTORY | O_NOFOLLOW`, the descriptor `fstat`ed and compared, and the descriptor itself listed; on Windows, which lists only by name, the name is `lstat`ed, compared and refused as a link (a name-surrogate reparse point) just before the `scandir`, leaving the window between those two calls; a reparse folder that holds its own contents, such as a cloud-files placeholder, is walked like any other folder. A folder swapped in between -- renamed away with another directory or a link put at its name -- is shown as a row, named in `unreadableDirectories` (a row that claims nothing about its contents, never `truncatedDirectories`, which every consumer renders as the item limit), and nothing behind it is listed; a root swapped for a link after the handler resolved it fails its `O_NOFOLLOW` open the same way and is named `.`. Response `{root, paths: [], directories: [], repo, truncated?, truncatedDirectories: [], hiddenOnlyDirectories: [], unreadableDirectories: [], linkedDirectories: [], nestedRepoDirectories: []}`. `paths` and `directories` TOGETHER carry at most `_PROJECT_TREE_MAX_ENTRIES` (10,000) rows on both branches (`_project_tree_allot`): directory rows are spent first, shallowest first (the walk's breadth-first discovery order; the git branch orders its derived folders by depth, then segment by segment, which is the same order), because a folder row is what lets the user navigate to the rest -- but never past half the cap while there are files to show, so a tree with more folders than the cap still lists its root files (`README.md` included) -- and the files share their rows round-robin across their direct parent directories, the root first, so no one folder takes every row; half is a floor for each side, not a split, and rows the files cannot use go back to folder rows without those folders' files then sharing the file rows; a file whose folder lost its row is not listed either. Empty directories on the filesystem-walk path are rows like any other. Folder rows are capped together with files rather than kept as an uncapped skeleton, because uncapped folder rows make the walk, the per-row redaction and the serialization linear in the size of the tree, and the tree is refetched every 10 s while it is open (paused while the read is failing), so a large non-git project dir would stall the gateway. The client bounds each read: 15 s for the file search and the tree (`FILE_SEARCH_TIMEOUT_MS`), 10 s for the browse and recent-project listings (`BROWSE_FILES_TIMEOUT_MS`); a timed-out or failed read gets a cause-keyed notice, with Refresh/Retry offered only for those two causes. On the walk the file fairness is over what the walk READ: a folder it never reached contributes no file and, having a row, is named in `truncatedDirectories`. `hiddenOnlyDirectories` is walk-only: it names the directories the walk leaves childless although they are not empty on disk (no file or directory the listing keeps remains, and every entry is a `_PROJECT_TREE_SKIP_DIRS` directory or a fenced entry, so a `.docker/` holding only a fenced `config.json` is hidden-only), so the tree can tell them from genuinely empty ones (`_bg/` holding only `.kiro/` is the reported case); a directory with a listed file or a kept subfolder is never in it even when it also holds hidden entries; the root is judged by the same rule and, when its top level holds only such entries, is named as `.` (it is no directory row of its own), because the payload would otherwise be the empty-workspace shape and the dashboard would call the workspace empty — the frontend then shows a root-specific notice in the tree's place instead of the empty-workspace one (`This workspace contains only hidden items (skipped tool and cache folders, protected entries)`, `root_hidden_only` — not the per-folder row's wording, which with no folder row above it read as being inside a folder), with no action row since there is nothing to act on; and the git branch returns `[]` by construction, because a directory row exists there only as an ancestor of a listed file, so an ignored-only folder is absent rather than childless and the only childless directory that branch can produce is a truncated one. `linkedDirectories` is walk-only too: a symlink to a directory is a visible, navigable entry (`ls` shows it), so it does NOT make its folder hidden-only; the walk never follows a link (`_project_tree_is_link`: a symlink or a name-surrogate reparse point such as a Windows junction, `platform_compat.lstat_is_name_surrogate`; against link cycles) and so never reads beneath it, so the walk lists it as a directory row of its own and names it here — nothing beneath it is listed, and the frontend puts `Link to another folder: contents not listed` (`row_linked`) beneath its row instead of calling the link, or the folder holding only links, empty; links are told apart among the entries the hidden/skip filter KEPT, because what a folder shows must be predictable from the name alone — a `node_modules` or `.cache` that is a link to a directory elsewhere is filtered exactly like its real twin and counts as a hidden entry (a folder holding nothing else is hidden-only), so link-ness the user cannot see never decides visibility; a link the filter keeps is a row, and a folder holding only kept links is not hidden-only; inside a repository the list is empty by construction (git lists a symlink as a file). `nestedRepoDirectories` is git-only: `git ls-files` names a nested repository as ONE entry and never lists inside it -- a submodule is a gitlink (mode 160000), printed like a file, and an untracked nested clone is printed with a trailing slash -- so the git branch (`_project_tree_nested_repos`) reads the gitlinks from a second, index-only `git ls-files --stage`, made only when the repository has a `.gitmodules` at its top (`_project_tree_has_gitmodules`, one `lstat`, located through the `--show-cdup` the existing `git rev-parse` probe already prints) because each git run costs a sandbox start and the tree is polled every 10 s, so a repository without submodules pays nothing (a failed read, or a gitlink with no `.gitmodules`, leaves it as the file row it was listed as), lists each such entry as a directory row instead of a file, and names it here; a folder's own listing (`?dir=`) is how its contents are reached (see below). It is not a cut: it never sets `truncated` or `truncatedDirectories`, and its row shows neither the item-limit wording nor the cut badge. Only rows the payload shows are named, like every other marker; the walk branch returns `[]` by construction, because it descends every folder itself. `unreadableDirectories` is walk-only too: a folder is a row as soon as its parent's read names it, so a kept, non-link child the process cannot read (permission denied is the usual cause) is still a row of its own, and when its own read fails -- `scandir` refusing it, its listing failing part-way, in which case what it yielded first is discarded rather than shown as if it were the folder, or the `lstat` that records its identity failing after its parent listed it (a parent readable but not searchable) -- it is named here rather than in `truncatedDirectories`, so the tree shows the folder while its parent is not childless at all; any read failure counts, not only `EACCES`, because the parent listed the entry and a row that makes no claim about its contents is the honest rendering whatever stopped the read. An unreadable folder whose row falls past the row cap is neither a row nor named here (its parent is in `truncatedDirectories` instead): this list names only folders the payload shows, so the notice below never names a folder the tree has no row for, and the same holds for `hiddenOnlyDirectories` and `linkedDirectories`. Because the value behind that entry is a failed read, it is an error and reaches the user only through `ErrorNotice`: the frontend renders one above the tree naming the folders (`Folders not readable: `, `unreadable_folders`, hand-off on since the listing holds no draft; always the alert in the danger tone — an error is never toned down to a status or a muted register, only shown or, at the user's word, not shown. Its ✕ DISMISSES it: the control is named `dismiss_unreadable` (`Dismiss: hide this notice until the unreadable folders change`), carried as its `aria-label` AND its `title`, so the promise a remembered dismissal makes is read before the click by hover as well as by a screen reader; the click leaves, where the notice stood, a one-line `role="status"` "Notice hidden — N folders still unreadable (shown again when they change) — Undo" (`unreadable_dismissed`, a plural key on the count of folders the payload still names, + `undo_dismiss_unreadable`; muted, saying what the ✕ did and did not do — hidden, not fixed, how many folders are still unreadable and that the notice returns when they change — but never which folders: a count is not a toned-down copy of the error's text) for the product's one undo window (`MOVE_UNDO_MS` on `useHeldWindow`: held while the pointer is over that spot or focus is inside it, retired for good when the notice returns on its own, never carried across a remount; the focus hold is cleared at every swap of the spot's content, because the control that was activated had focus and a removed element fires no `focusout` — left alone, the hold would pin the offer open for the life of the mount — and when that control had focus, focus is handed to its counterpart, Undo after the ✕ and the ✕ after Undo, so a keyboard dismissal lands on the way back rather than at `` with the window running) whose Undo restores the alert and CLEARS the remembered set, so a mis-click on a ✕ that sits beside "Ask the agent" at the same weight has a visible way back; the dismissal remembers the folders the notice named for that project — per project directory, in the expansion memory's own store under its own key (`treeUnreadableDismissals.ts`, one `createProjectPathMemory` instance: module map for the remount, `localStorage` mirror for the reload, the same caps) — and while every folder the payload names is remembered the notice is not rendered, so a folder that stays unreadable by design is not red alarm chrome on every visit; a folder outside the remembered set brings the alert back with the whole list, and a remembered folder that drops out of the payload is forgotten (the set is pruned to each landed payload, never to a mount that has not heard from the server yet, and in a `truncated` payload a remembered folder absent from it is kept while the cut could be what hid it -- its parent is absent too or is itself in `truncatedDirectories` -- and forgotten when its parent is listed whole), so the same folder failing anew alerts again rather than inheriting its old dismissal), plans no state row beneath such a folder (nothing beneath it is known, and a status line inside Pierre's shadow root can carry neither the hand-off nor an error), and gives the folder's own row a lock marker whose accessible label points at that notice (`Not readable — see the notice above`, `row_unreadable_marker`, Pierre's decoration `title`: the tooltip and the accessible name of the decoration span; once the notice is dismissed the label says so instead, `Not readable — notice dismissed`, `row_unreadable_marker_dismissed`, so it never sends the reader to a notice that is not there) — a signpost to where the failure is reported, not a report of it: it states no failure, carries no hand-off of its own, and is fed by the same `unreadableDirectories` list the notice renders, so the reader can tell that folder from the ones that show nothing for another reason and can never see a marker the notice does not name; the folder is never called empty, and the notice and the markers follow the payload and go when the folders read again. Its files are never listed (nothing read them); the root itself failing is recorded as `.` — the walk then yields nothing, so without it the payload would be indistinguishable from a workspace with no files in it, and the root is no directory row of its own, so it is named only here (the frontend shows, in the tree's place, an `ErrorNotice` whose lead is `No permission to read the workspace folder` (`root_unreadable` — it names its cause, so it cannot be read as the host's own `Couldn't load the file tree`, which is a failed request where this listing answered; permission is the failure a directory that passed the `isdir` check produces, and a root removed between that check and the walk shows the same notice for one refresh) and whose message is the redacted `root` path in a span of its own, with a zero-width break hint after each `/` so a narrow rail wraps it at a segment boundary rather than mid-word (a Windows-shaped root — drive letter or UNC — is shown in its forward-slash form first, `normalizeWindowsPath`, since its backslash segments would get no break point; any other path is untouched) — display only: the notice's `report` carries the sentence and the real path to the agent — followed by the hand-off and a Refresh that re-asks the listing, the same two actions as the host's failed-listing notice, instead of the empty-workspace notice; one level down the row sits under the folder it qualifies, here no folder row exists to anchor it); and the git branch returns `[]` by construction for the same reason as `hiddenOnlyDirectories`: `--others` cannot scan a directory git cannot read, so it contributes no untracked file and, with no indexed file beneath it, is absent rather than childless, while an indexed path beneath it still comes from the index (`--cached` reads no directory) and makes it an ordinary populated row. The frontend passes those directories to Pierre as explicit trailing-slash paths, keeps the workspace-level truncation notice, and appends one synthetic state row beneath every folder the payload leaves childless (`website/src/pierre/treeStateRows.ts`): `Some items not shown: limit of 10,000 items reached` for a `truncatedDirectories` entry (checked first, since a folder that lost its contents to the cap is not empty; the workspace-level notice, `workspace_truncated`, names the same `limit of 10,000 items reached`, so the two are read as one fact; neither promises that every folder is listed, because the cap counts folder rows too), no row for a `nestedRepoDirectories` entry that is not also truncated (its own listing is a local read that loads on expand, so a status line would only flash; it is never labelled empty), `Link to another folder: contents not listed` for a `linkedDirectories` entry (checked before hidden-only: a link is never walked, so it is neither truncated nor hidden-only), `Contains only hidden items (skipped tool and cache folders, protected entries)` (`row_hidden_only`) for a `hiddenOnlyDirectories` entry (the cause, as `row_truncated` names its own — the parenthetical says what "hidden" means here, items the listing hides by nature, not items the app cut off), and `Empty folder` otherwise (an `unreadableDirectories` entry is skipped before the choice, whatever else names it, so a folder nothing read is never called empty; the order only fixes what a malformed payload shows). A truncated folder's row also carries the `some items not shown` badge (`workspace_directory_truncated`; the same "not shown" vocabulary as its state row, so "hidden" is reserved for what the listing hides), and the badge yields while the folder is expanded over its own state row, so the cut is stated once at a time (except under an `Open in folder tab` row, which does not state the cut, so the badge stays). `@pierre/trees` renders rows for paths and has no slot for a status line, so each state row is fed to the model as one synthetic child path whose basename is the localized label followed by a trailing zero-width space (U+200B, `STATE_ROW_MARKER`) — the row's identity in the plan, and only that: a real file can end in U+200B too, so the styling never keys on the path. The wrapper's `renderRowDecoration` gives a planned row (a path in the plan's set, the same set its selection and context-menu guards read) a decoration icon `STATE_ROW_ICON` — an `` with no symbol behind it — and `PIERRE_TREE_STATE_ROW_CSS` selects the row carrying that decoration (`:has()`), never the path suffix and never the label, so a real file named `x` plus U+200B, or one named "Empty folder", keeps its icon, its pointer and its open; the wrapper keeps the planned row inert (no open, no selection, no context menu) with one exception: where the host passes `onOpenFolder` (the Files rail, in the side panel and the pinned Files tab), a `truncatedDirectories` row reads `Open in folder tab` (`row_truncated_open`), carries its own decoration (`STATE_ROW_ACTION_ICON`, link-styled with the pointer) and, when selected, is deselected and hands its project-relative folder to the host, which opens that folder's tab (`FolderPanel`, the per-folder listing and name search with a growing page); that row still has no open, drag or context menu, and the folder's `some items not shown` badge stays on beside it because the row no longer names the cut, and a `/` in a translated label is rewritten to the division slash (U+2215) so a label can never mint a subfolder. While the panel's filter is active only the state rows whose FOLDER matches the query are fed to the model (`stateRowsMatchingFilter`: the folder's listed `dir/` path, lower-cased, contains the query as Pierre normalizes it — trimmed, backslashes read as slashes, lower-cased), because two things hold at once: Pierre matches every listed path, so a row fed on a match of its label alone ("hidden", "folder") would surface a status line as a search result; and Pierre force-expands every matching directory in its hide-non-matches mode, re-applying that on every store event, so a matched childless folder cannot be kept closed and, with its row withheld, would open over nothing — the down-chevron with no row beneath it that the state row exists to prevent. A fed row matches through the same folder prefix and sits under its open folder, the badge yielding to it as outside a filter; a blank query feeds the whole plan. Pierre focuses the first match, and the fed row sorts first under its folder, so when that focus lands on a state row the wrapper hands it to the folder the row qualifies (`focusPath`, which raises no store event) — a focus ring never sits on a status line. The two resets the filter brackets (filter on, filter off) carry the model's expansion in a host that does not persist it, because Pierre snapshots the expansion when a filter opens and restores it when it closes. No "loading" or "error" state row exists: whether a folder has children is decided by the payload the moment it lands and never by a fetch in flight; Changed mode plans no state rows at all, since every folder there is the parent of a changed file. `truncated` is true exactly when `truncatedDirectories` is non-empty -- files or folder rows were omitted. A truncated payload with no row at all (the walk stopped before it reached anything it lists, such as a root whose first entries are all hidden folders) shows its own `root_truncated` sentence, `Workspace too large to list: limit of 10,000 items reached`, in the empty-workspace notice's place (`workspace-tree-root-truncated`), since nothing there is known to be empty. Pierre's `flattenEmptyDirectories` folds a folder holding one listed subfolder and no file into one chain row whose path names its LAST folder, so the badge is looked up for every folder in the row's `flattenedSegments`, and yields to the state row only when the chain's last folder is the one truncated folder in it. Older clients may ignore the six additive arrays. `root`, every file path, every directory path, every truncation marker, every hidden-only marker, every linked marker, and every unreadable marker pass through the same segment-aware redaction before egress -- in the same worker thread as the listing and the JSON serialization (`_project_tree_body`), because each is linear in the size of the listing and none may run on the event loop; `root` is an absolute project path like `/api/project/git`'s `repoRoot`, so it takes the same Darwin temp-prefix exception described above, while the project-relative paths carry no OS-owned prefix and stay on the canonical redactor. **Folders past the cap**: `?dir=` lists one project-relative folder instead of the whole tree, under the same row cap, its paths still project-relative and the folder echoed as `dir`. Every check on `path` (the known-project allow-list, the sensitive-root refusal, the audit record) runs first and unchanged; `dir` must then be a plain relative path (no empty, `.` or `..` segment, no leading `/` or NUL, and on Windows no drive, `\` or `:`) whose real path is exactly its plain join under the root, so any link along it is refused, and strictly inside the root and not sensitive (`_project_tree_subdir`; `dir_refused` 403, `dir_not_found` 404, each audited as denied). The folder is then pinned: `_project_tree_pin` reaches it through `pinned_fs.open_pinned_descendant_dir` (a descriptor walk refusing a link at every component; on Windows that helper's `lstat` per prefix and its documented by-name residual) and records its `(st_dev, st_ino)`; the walk reads it only while it is still that folder (`_project_tree_walk_pinned`), and git lists it from the project root with the folder as a literal pathspec (`GIT_LITERAL_PATHSPECS`) rather than as its working directory, so an ancestor swapped for a link after the check lists nothing outside the project. A folder at or inside a nested repository is listed by that walk too (`_project_tree_in_nested_repo`: any folder from the project root down to it, itself included, has a `.git` of its own): the project's git cannot list it (for a submodule it names the gitlink and nothing beneath it, and for a path beneath one it refuses), and running git with the nested folder as its working directory would give up the pinned-descriptor guarantee above. The consequence is that such a listing does not honour the nested repository's own `.gitignore` (the walk still skips `_PROJECT_TREE_SKIP_DIRS`, and `_project_tree_listing` drops the `.git` entries it finds, which git's own listing never names), and a repository inside it is an ordinary folder, never a `nestedRepoDirectories` entry. On a platform that cannot pin by descriptor (Windows, where git walks into a junction) a folder listing uses the identity-checked walk instead of git, so it does not honour `.gitignore` there (the walk still skips `_PROJECT_TREE_SKIP_DIRS`); a git listing that names nothing is not shown as an empty folder row. The tree (`PierreWorkspaceTreeImpl`) asks for a folder's own listing when the user expands a folder named in `truncatedDirectories` or `nestedRepoDirectories`, or when a host reveals one, under `['project-tree', projectDir, 'dir', folder]` so the host's Refresh re-reads it (not polled on their own: each whole-tree poll that lands re-reads at most three of the loaded folders that are open, in turn, since the whole listing cannot see a change inside a folder past the cap; a listing whose latest read fails is named in a notice above the tree, `folder_listing_failed`, whether or not earlier rows are still shown, with its own Retry button beside it, `folder_listing_retry`, that re-reads those folders); at most 20 folder listings, and at most 40,000 rows across them (`MAX_FOLDER_LISTING_ROWS`, the newest listing always kept), are kept per mount (a folder whose listing that bound drops while it is open is not reloaded until it is closed, so the bound never cycles), and `mergeFolderListings` (`pierre/treeFolderListings.ts`) folds them into the rendered payload, a loaded folder and every folder beneath it leaving `truncatedDirectories` unless its own listing cuts them again; a loaded folder whose listing answers 404 (deleted since) or 403 (refused, e.g. a link) is dropped and, like one the bounds drop, not reloaded until it is closed, while a notice of its own naming it, `folder_gone` for a 404 or `folder_refused` (a link or a protected folder) for a 403, with no retry since none can change it (a 404 also re-reads the whole listing once, which still named the folder), stays until the user dismisses it or a later load of that folder succeeds; a revealed folder is expanded, focused and selected. While the whole payload is truncated the Files rail (`FileBrowserRail`) sends its name filter to `/api/file-search` (`NameSearchResults`, files and folders, 50 rows) instead of the tree's local filter, under the folder tab's own note (`search_beyond_tree`; the same budgeted walk, the same caveat), its failure copy the folder tab's own (`SEARCH_FAILURE_KEYS`, `lib/searchErrorCause.ts`), its hits joined by the cut tree's own matching files through the folder tab's own `mergeTreeMatches` (`lib/treeSearchMatches.ts`), also when the search fails, the tree kept mounted behind it; a folder hit reveals that folder in the tree and a file hit opens it. **Theme / display**: GET `/api/theme/boot` (**unauthenticated** — same boundary as `/api/health`; returns `{mode, color, language, onboarded, import_onboarded, privacy_acked, crewmates_onboarded}` so the SPA can apply the workspace theme, pick its UI language, and resolve onboarding gates before the token flow completes; no secrets), GET/PUT `/api/config/theme` (read/persist workspace display prefs `{mode?, color?, language?, onboarded?, import_onboarded?, privacy_acked?, crewmates_onboarded?}`; `mode` restricted to `""`/`dark`/`light`/`system`; `language` is `""` (auto-detect from the browser) or a BCP-47 tag validated for **shape only** via `_LANGUAGE_TAG_RE`, so adding a shipped language stays a frontend-only data change; the PUT validates the whole body before writing and persists ONLY the named `dashboard.*` keys through `run_config_write(update_config_locked, ...)`, never a whole-document `save()`, and answers an unreadable `config.json` with `500 {"code": "config_unreadable"}` leaving the file untouched, a write the config guard refuses with `400 {"code": "config_write_refused"}`, and a failed write with `500 {"code": "config_write_failed"}` — see `config.md` → "`KiroCrewConfig.save() -> None`"). All four response sites are built by one helper (`handlers/core.py::_theme_payload`) so a newly-added display pref cannot be surfaced by some and omitted by others — see `config.md` → "Dashboard UI language". **Foreign-agent import**: authenticated GET `/api/onboarding/import/scan`, POST `/api/onboarding/import/apply`, PUT `/api/onboarding/import/state`; see "Foreign-Agent First-Run Import". diff --git a/src/kiro_crew/acp/client_lifecycle.py b/src/kiro_crew/acp/client_lifecycle.py index 7489ca68a46..5480c26b78a 100644 --- a/src/kiro_crew/acp/client_lifecycle.py +++ b/src/kiro_crew/acp/client_lifecycle.py @@ -44,7 +44,7 @@ class binds it by name, so it reads and writes the session's own state on the in import time from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar -from kiro_crew import platform_compat +from kiro_crew import platform_compat, repo_checkout_guard from kiro_crew.acp import runtime_process_tree, seed_provenance from kiro_crew.acp._dispatch import BackgroundLaunchRecord from kiro_crew.acp.client_spawn_env import KIRO_CLI_BIN @@ -572,6 +572,9 @@ async def _kill_process(self: AcpClient, *, force: bool = False) -> None: def _reset_state(self: AcpClient) -> None: """Reset all session state (call after process is dead).""" + # Whose prompts a confirmed death below ends (repo_checkout_guard); read + # before the reset clears it. + dying_instance = str(getattr(self, "_process_instance", "") or "") cleanup = getattr(self._process, "_windows_cleanup_state", None) if platform_compat.IS_WINDOWS and isinstance( cleanup, platform_compat._PendingWindowsTreeCleanup @@ -816,6 +819,8 @@ def _reset_state(self: AcpClient) -> None: ) self._child_pids = {} self._process_tree_confirmed_dead = root_confirmed_dead and not survivors + if self._process_tree_confirmed_dead: + repo_checkout_guard.prompt_process_gone(dying_instance) async def _classify_failed_start(self: AcpClient, classify: Callable[[], Awaitable[_T]]) -> _T: diff --git a/src/kiro_crew/acp/client_transport.py b/src/kiro_crew/acp/client_transport.py index 67650bd0c35..73ac5f5fde7 100644 --- a/src/kiro_crew/acp/client_transport.py +++ b/src/kiro_crew/acp/client_transport.py @@ -15,8 +15,9 @@ class binds it by name, so it reads and writes the session's own state on the in Instance fields read: ``_buffer``, ``_cancel_grace_secs``, ``_cancel_ts``, ``_cancelled``, ``_kill_sent``, ``_mcp_report``, ``_mode_frame_awaited``, -``_native_skill_projection``, ``_process``, ``_stderr_lines``, ``_stderr_task``, -``_turn_done``, ``memory_mode``. +``_native_skill_projection``, ``_process``, ``_process_instance``, +``_spawn_work_dir``, ``_stderr_lines``, ``_stderr_task``, ``_turn_done``, +``memory_mode``. """ from __future__ import annotations @@ -29,6 +30,7 @@ class binds it by name, so it reads and writes the session's own state on the in from contextlib import aclosing from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, TypeVar +from kiro_crew import repo_checkout_guard from kiro_crew.acp import transport_framing from kiro_crew.acp._dispatch import _loggable_request_id from kiro_crew.acp._frame_record import record_frame @@ -224,6 +226,15 @@ async def _send_request( if not self._process or not self._process.stdin: raise AcpError("ACP process not running") + is_prompt = repo_checkout_guard.is_tracked(method) + if is_prompt: + # The branch switch's busy check sees every prompt from before its + # write until the backend answers it (repo_checkout_guard). No await + # without a checkout in flight, so nothing reorders here. + folder = str(getattr(self, "_spawn_work_dir", "") or "") + await repo_checkout_guard.before_prompt(folder) + if not self._process or not self._process.stdin: + raise AcpError("ACP process not running") projection = getattr(self, "_native_skill_projection", None) if projection is not None: params = projection.request(method, params) @@ -246,6 +257,13 @@ async def _send_request( # co-tenant waits behind the lock, but a single session can self-wedge # too). A stall raises AcpProcessDied, the same recovery a closed pipe # already gets. + if is_prompt: + # Counted from just before the write, with no await since the + # admission above; a frame that never reaches the pipe drops it + # again (_write_request_bounded). + repo_checkout_guard.prompt_sent( + str(getattr(self, "_process_instance", "") or ""), req_id, folder, method=method + ) await self._write_request_bounded(data, req_id, method) except (BrokenPipeError, ConnectionResetError) as exc: raise AcpProcessDied(f"ACP process pipe broken: {exc}") from exc @@ -285,14 +303,33 @@ async def _write_request_bounded( ``ensure_ready`` kills the child before spawning its replacement. """ assert self._process is not None and self._process.stdin is not None - result = await write_request_frame_bounded( - self._process.stdin, - self._stdin_write_lock(), - data, - bound_secs=transport_framing._RESPONSE_WRITE_BOUND_SECS, - ) + # A prompt is counted before its write (repo_checkout_guard); one whose + # frame never reached the pipe is dropped again, since nothing will answer + # it. ``written`` turns true under the lock, just before the synchronous + # ``stdin.write``, so from there on the frame counts as delivered. + written = False + + def _before_write() -> None: + nonlocal written + written = True + + instance = str(getattr(self, "_process_instance", "") or "") + try: + result = await write_request_frame_bounded( + self._process.stdin, + self._stdin_write_lock(), + data, + bound_secs=transport_framing._RESPONSE_WRITE_BOUND_SECS, + before_write=_before_write, + ) + except BaseException: + if not written: + repo_checkout_guard.prompt_unsent(instance, request_id) + raise if result is RequestWriteResult.OK: return + if result is RequestWriteResult.LOCK_STALL: + repo_checkout_guard.prompt_unsent(instance, request_id) safe_id = _loggable_request_id(request_id) window = response_write_window_secs( self._process.stdin, transport_framing._RESPONSE_WRITE_BOUND_SECS @@ -764,6 +801,13 @@ async def _read_message( projection = getattr(self, "_native_skill_projection", None) if projection is not None: data = projection.frame(data) + if data.get("method") is None and ("result" in data or "error" in data): + # A response, read off the wire once: if it answers a prompt, the + # backend has ended that turn, whichever loop reads it (a later turn + # reads the answer to one a cancelled caller stopped waiting on). + repo_checkout_guard.prompt_answered( + str(getattr(self, "_process_instance", "") or ""), data.get("id"), data.get("result") + ) return JsonRpcMessage( id=data.get("id"), method=data.get("method"), diff --git a/src/kiro_crew/acp/runtime_class.py b/src/kiro_crew/acp/runtime_class.py index 3a360b9d8bb..ed50995cd62 100644 --- a/src/kiro_crew/acp/runtime_class.py +++ b/src/kiro_crew/acp/runtime_class.py @@ -99,6 +99,7 @@ class AcpRuntime: _snapshot_descendants = _rt_tree._snapshot_descendants _signal_tree = _rt_tree._signal_tree kill = _rt_tree.kill + _recheck_teardown_survivors = _rt_tree._recheck_teardown_survivors _kill_inner = _rt_tree._kill_inner # Launching the child process and its handshake: ``kiro_crew.acp.runtime_spawn_launch``. _discard_sandbox_cleanup = _rt_launch._discard_sandbox_cleanup @@ -174,6 +175,8 @@ class AcpRuntime: _refuse_if_view_superseded = _rt_mode._refuse_if_view_superseded _activate_mode_bracketed = _rt_mode._activate_mode_bracketed # JSON-RPC over the child's stdin: ``kiro_crew.acp.runtime_wire``. + _admit_prompt = _rt_wire._admit_prompt + _record_prompt = _rt_wire._record_prompt send_request = _rt_wire.send_request send_notification = _rt_wire.send_notification _refuse_write_if_dead = _rt_wire._refuse_write_if_dead @@ -469,6 +472,9 @@ def __init__( # (e.g. session/prompt response signals turn completion and must reach the session) self._routed_requests: dict[int, str] = {} self._session_queues: dict[str, asyncio.Queue[JsonRpcMessage | None]] = {} + # The process instance whose teardown left survivors, so a later re-check + # that confirms them gone can end its prompts. + self._teardown_instance = "" # sessionId -> the ``model`` kiro-cli's last ``_kiro.dev/agent/switched`` # named for it. ``session/set_mode`` answers ``{}``; this notification, # sent before that answer, is the only report of the model the activated @@ -532,6 +538,10 @@ def __init__( # descendant survived. Consumers use it to decide whether a work # directory is safe to reclaim. self._process_tree_confirmed_dead = False + # The last teardown's own verdict: whether it proved the root dead, and + # the descendants it left alive for the orphan sweep (see ``kill``). + self._teardown_root_dead = False + self._teardown_survivors: dict[int, ChildRecord] = {} self._death_summary: str | None = None # The composed summary's parts, so the post-reap amendment rebuilds the # line instead of editing its text -- a tail carrying this format's own diff --git a/src/kiro_crew/acp/runtime_reader_loop.py b/src/kiro_crew/acp/runtime_reader_loop.py index 9e14eb9b1d2..d8fc67f6a79 100644 --- a/src/kiro_crew/acp/runtime_reader_loop.py +++ b/src/kiro_crew/acp/runtime_reader_loop.py @@ -12,7 +12,7 @@ Instance fields read: ``_acp_backend``, ``_kas_host_auth``, ``_launch_cwd_roots``, ``_map_launch_cwd_paths``, ``_max_answer_tasks``, -``_mcp_secret_server``, ``_native_skill_projection``, ``_process``, +``_mcp_secret_server``, ``_native_skill_projection``, ``_process``, ``_process_instance``, ``_session_inits_in_flight``, ``_session_queues``, ``_start_collectors``, ``_subagent_owner``, ``_subagent_roster_overflow``, ``_subagent_sessions``, ``_turn_active_sessions``, ``recording_allowed``. @@ -26,6 +26,7 @@ import time from typing import TYPE_CHECKING, Any +from kiro_crew import repo_checkout_guard from kiro_crew.acp import kas_secret_storage from kiro_crew.acp._frame_record import record_frame from kiro_crew.acp.harness import NotificationAliases @@ -344,6 +345,12 @@ async def _reader_loop(self: AcpRuntime) -> None: logger.debug("Response with non-numeric id %r dropped", msg.id) continue + # The backend has ended the turn of a prompt it answers, + # whether or not anyone still waits on it. Matched on the id + # exactly as the wire carried it, not the coerced one. + if msg.method is None: + repo_checkout_guard.prompt_answered(self._process_instance, msg.id, msg.result) + # Check awaited requests first (init, session/new, set_mode) future = self._pending_requests.pop(req_id, None) if future and not future.done(): diff --git a/src/kiro_crew/acp/runtime_spawn_entry.py b/src/kiro_crew/acp/runtime_spawn_entry.py index 6c2cc543113..a2208f61f8d 100644 --- a/src/kiro_crew/acp/runtime_spawn_entry.py +++ b/src/kiro_crew/acp/runtime_spawn_entry.py @@ -10,7 +10,8 @@ Instance fields written: ``_dead``, ``_death_expected``, ``_death_label``, ``_death_reason``, ``_death_summary``, ``_death_tail``, ``_process_tree_confirmed_dead``, ``_saw_auth_failure``, ``_saw_delegate_launch_failure``, ``_saw_sandbox_init_failure``, ``_scratch_dir``, -``_stall_turn_sessions``, ``_stderr_lines``, ``_stdin_stall_death``. +``_stall_turn_sessions``, ``_stderr_lines``, ``_stdin_stall_death``, ``_teardown_root_dead``, +``_teardown_survivors``. Instance fields read: ``_acp_backend``, ``_answer_tasks``, ``_initialized``, ``_process``, ``_session_queues``. @@ -53,6 +54,8 @@ async def spawn( if self._process is not None: raise AcpRuntimeError("Runtime already spawned") self._process_tree_confirmed_dead = False + self._teardown_root_dead = False + self._teardown_survivors = {} admission = runtime_start._cold_start_admission() notify_start_queue(logger, on_gate_queued, START_QUEUE_ADMISSION) @@ -203,6 +206,8 @@ async def _revive_after_failed_start(self: AcpRuntime) -> bool: self._scratch_dir = None self._dead = False self._process_tree_confirmed_dead = False + self._teardown_root_dead = False + self._teardown_survivors = {} self._death_summary = None self._death_reason = "" self._death_label = "" diff --git a/src/kiro_crew/acp/runtime_spawn_launch.py b/src/kiro_crew/acp/runtime_spawn_launch.py index b2e08f84178..826abe9f8d8 100644 --- a/src/kiro_crew/acp/runtime_spawn_launch.py +++ b/src/kiro_crew/acp/runtime_spawn_launch.py @@ -18,7 +18,8 @@ ``_pid``, ``_process``, ``_process_instance``, ``_process_tree_confirmed_dead``, ``_prompt_capabilities``, ``_reader_task``, ``_sandbox_cleanup``, ``_scope_unit``, ``_skill_projection_generation``, ``_spawn_monotonic``, ``_spawn_skill_projection``, -``_spawn_start_token``, ``_start_time``, ``_stderr_task``, ``_tool_search_wire``. +``_spawn_start_token``, ``_start_time``, ``_stderr_task``, ``_teardown_root_dead``, +``_teardown_survivors``, ``_tool_search_wire``. ``_with_mcp_sign_in_storage`` (a plain function handed the runtime) also writes ``_mcp_secret_server``. @@ -1138,12 +1139,16 @@ async def _kill_failed_start_escapees(self: AcpRuntime, escapees: dict[int, Chil await asyncio.sleep(0.05) except Exception: self._process_tree_confirmed_dead = False + # Inconclusive: no later kill may read the tree as confirmed. + self._teardown_root_dead = False logger.warning( "AcpRuntime: could not reap the descendants of a failed start", exc_info=True ) return if survivors: self._process_tree_confirmed_dead = False + # Left for a later kill to re-check, with the kill's own survivors. + self._teardown_survivors.update({pid: escapees[pid] for pid in survivors}) logger.warning( "AcpRuntime: %d descendant PID(s) of a failed start survived SIGKILL; " "left tracked for the orphan sweep: %s", diff --git a/src/kiro_crew/acp/runtime_tree_kill.py b/src/kiro_crew/acp/runtime_tree_kill.py index fa25534b78e..e5256fa5b2a 100644 --- a/src/kiro_crew/acp/runtime_tree_kill.py +++ b/src/kiro_crew/acp/runtime_tree_kill.py @@ -10,10 +10,12 @@ Instance fields written: ``_bound_workspace_fd``, ``_child_pids``, ``_process``, ``_process_instance``, ``_process_tree_confirmed_dead``, ``_scope_unit``, -``_spawn_work_dir``. +``_spawn_work_dir``, ``_teardown_instance``, ``_teardown_root_dead``, +``_teardown_survivors``. Instance fields read: ``_close_remote_workspace``, ``_descendant_scan_lock``, -``_exit_watch_task``, ``_pid``, ``_reader_task``, ``_spawn_start_token``, ``_start_time``, ``_stderr_task``, ``_work_dir``. +``_exit_watch_task``, ``_pid``, ``_reader_task``, ``_recheck_teardown_survivors``, +``_spawn_start_token``, ``_start_time``, ``_stderr_task``, ``_work_dir``. """ from __future__ import annotations @@ -23,7 +25,7 @@ import logging from typing import TYPE_CHECKING -from kiro_crew import platform_compat +from kiro_crew import platform_compat, repo_checkout_guard from kiro_crew.acp import runtime_process_tree from kiro_crew.acp.runtime_process_tree import ChildRecord, _capture_child_records, _get_child_pids from kiro_crew.executors import subprocess_executor @@ -448,12 +450,33 @@ async def kill(self: AcpRuntime, *, expected: bool = False, reason: str = "") -> self._process_tree_confirmed_dead = False try: await self._kill_inner(expected=expected, reason=reason) + if self._process is None and self._teardown_root_dead: + await self._recheck_teardown_survivors() finally: self._discard_sandbox_cleanup() await self._discard_bound_workspace() await self._close_remote_workspace() +async def _recheck_teardown_survivors(self: AcpRuntime) -> None: + """Re-read the last teardown's verdict on a kill that found no process. + + That teardown proved the root dead and left its surviving descendants + tracked for the orphan sweep, so a repeat kill has nothing to signal and + would otherwise leave ``_process_tree_confirmed_dead`` False for good, + even after the sweep (or the descendant itself) has ended them. Each + survivor is pruned by its own recorded identity, as the teardown did. + """ + if self._teardown_survivors: + alive = await asyncio.to_thread(_prune_dead_descendants, dict(self._teardown_survivors)) + self._teardown_survivors = { + pid: rec for pid, rec in self._teardown_survivors.items() if pid in alive + } + self._process_tree_confirmed_dead = not self._teardown_survivors + if self._process_tree_confirmed_dead: + repo_checkout_guard.prompt_process_gone(getattr(self, "_teardown_instance", "")) + + async def _kill_inner(self: AcpRuntime, *, expected: bool = False, reason: str = "") -> None: """Kill the subprocess and clean up all state. @@ -470,6 +493,8 @@ async def _kill_inner(self: AcpRuntime, *, expected: bool = False, reason: str = # self._dead internally; doing it up front (before teardown) ensures any # waiters learn the runtime died. Calling it after setting _dead=True # would hit its early-return guard and skip all cleanup. + # Whose prompts a confirmed death below ends (repo_checkout_guard). + dying_instance = str(getattr(self, "_process_instance", "") or "") self._mark_dead(f"killed ({reason})" if reason else "killed", expected=expected) if self._reader_task and not self._reader_task.done(): @@ -542,6 +567,9 @@ async def _kill_inner(self: AcpRuntime, *, expected: bool = False, reason: str = # the placeholder true for a tree it could not confirm. self._note_reaped_after_kill(process.returncode) self._process_tree_confirmed_dead = process.returncode is not None + self._teardown_root_dead = process.returncode is not None + self._teardown_survivors = {} + self._teardown_instance = dying_instance self._process = None self._process_instance = "" self._scope_unit = "" @@ -673,3 +701,10 @@ async def _kill_inner(self: AcpRuntime, *, expected: bool = False, reason: str = survivors, ) self._process_tree_confirmed_dead = root_confirmed_dead and not survivors + # Kept so a later kill that finds no process can re-read the verdict + # once the orphan sweep (or the descendant itself) has ended them. + self._teardown_root_dead = root_confirmed_dead + self._teardown_survivors = {pid: saved_children[pid] for pid in survivors} + # Whose prompts the re-check that confirms this tree gone ends + # (_recheck_teardown_survivors, which kill() runs next). + self._teardown_instance = dying_instance diff --git a/src/kiro_crew/acp/runtime_wire.py b/src/kiro_crew/acp/runtime_wire.py index 6384ab7d463..d64ffbedec4 100644 --- a/src/kiro_crew/acp/runtime_wire.py +++ b/src/kiro_crew/acp/runtime_wire.py @@ -11,9 +11,9 @@ Instance fields written: ``_last_activity``, ``_next_id``, ``_pending_requests``, ``_routed_requests``, ``_stdin_lock``. -Instance fields read: ``_acp_backend``, ``_agent``, ``_dead``, ``_launch_agent``, -``_native_skill_projection``, -``_one_at_a_time_sent``, ``_process``, ``_session_queues``, ``_stderr_lines``. +Instance fields read: ``_acp_backend``, ``_admit_prompt``, ``_agent``, ``_dead``, +``_launch_agent``, ``_native_skill_projection``, ``_one_at_a_time_sent``, ``_process``, +``_record_prompt``, ``_session_cwds``, ``_session_queues``, ``_stderr_lines``. """ from __future__ import annotations @@ -23,6 +23,7 @@ import time from typing import TYPE_CHECKING, Any, Callable +from kiro_crew import repo_checkout_guard from kiro_crew.acp.client import _loggable_request_id from kiro_crew.acp.runtime_start import _cold_start_counts from kiro_crew.acp.session_handle import AcpRequestTimeout, AcpRuntimeDead @@ -128,6 +129,8 @@ async def send_request( if self._dead: raise AcpRuntimeDead("runtime is dead") + folder = await self._admit_prompt(method, params) + projection = getattr(self, "_native_skill_projection", None) if method == METHOD_SET_MODE and self._launch_agent and params.get("modeId") == self._agent: # Re-selecting this runtime's own agent mid-session names its launch @@ -155,6 +158,7 @@ async def send_request( # other session. On a stall _write_request_bounded marks the runtime # dead and raises AcpRuntimeStdinStalled (an AcpRuntimeDead) -- caught # below so the routing registration is dropped like any other death. + self._record_prompt(method, req_id, folder) await self._write_request_bounded(data, req_id, method) except AcpRuntimeDead: self._routed_requests.pop(req_id, None) @@ -213,6 +217,39 @@ async def send_notification(self: AcpRuntime, method: str, params: dict[str, Any ) +async def _admit_prompt(self: AcpRuntime, method: str, params: dict[str, Any]) -> str | None: + """Wait out a branch switch before a request that starts agent work. + + ``None`` for any other request. For a prompt (``PROMPT_METHODS``), the + folder its session was opened in, or ``""`` when that is unknown (busy in + every repository), returned once no checkout covers it; with none in + flight, without suspending. Every request writer of this runtime calls it + before it reserves an id and :meth:`_record_prompt` just before the frame + write, with no await between (repo_checkout_guard); a frame that then never + reaches the pipe drops its record again (:meth:`_write_request_bounded`). + """ + if not repo_checkout_guard.is_tracked(method): + return None + folder = getattr(self, "_session_cwds", {}).get(str(params.get("sessionId") or ""), "") + await repo_checkout_guard.before_prompt(folder) + if not self._process or not self._process.stdin: + raise AcpRuntimeDead("process not running") + if self._dead: + raise AcpRuntimeDead("runtime is dead") + return folder + + +def _record_prompt(self: AcpRuntime, method: str, req_id: int, folder: str | None) -> None: + """Count a prompt :meth:`_admit_prompt` admitted until the backend answers it.""" + if folder is not None: + repo_checkout_guard.prompt_sent(_instance_of(self), req_id, folder, method=method) + + +def _instance_of(runtime: AcpRuntime) -> str: + """The process instance a prompt record of this runtime is keyed on.""" + return str(getattr(runtime, "_process_instance", "") or "") + + def _refuse_write_if_dead(self: AcpRuntime) -> None: """Re-check under the write lock, right before a frame goes out. @@ -338,15 +375,33 @@ async def _write_request_bounded( only through ``_loggable_request_id``. """ assert self._process is not None and self._process.stdin is not None - result = await write_request_frame_bounded( - self._process.stdin, - self._stdin_write_lock(), - data, - bound_secs=_RESPONSE_WRITE_BOUND_SECS, - before_write=self._refuse_write_if_dead, - ) + # A prompt is counted before its write (repo_checkout_guard); one whose + # frame never reached the pipe is dropped again, since nothing will answer + # it. ``written`` turns true under the lock, just before the synchronous + # ``stdin.write``, so from there on the frame counts as delivered. + written = False + + def _before_write() -> None: + nonlocal written + self._refuse_write_if_dead() + written = True + + try: + result = await write_request_frame_bounded( + self._process.stdin, + self._stdin_write_lock(), + data, + bound_secs=_RESPONSE_WRITE_BOUND_SECS, + before_write=_before_write, + ) + except BaseException: + if not written: + repo_checkout_guard.prompt_unsent(_instance_of(self), request_id) + raise if result is RequestWriteResult.OK: return + if result is RequestWriteResult.LOCK_STALL: + repo_checkout_guard.prompt_unsent(_instance_of(self), request_id) safe_id = _loggable_request_id(request_id) window = response_write_window_secs(self._process.stdin, _RESPONSE_WRITE_BOUND_SECS) logger.warning( @@ -425,6 +480,7 @@ async def send_request_for_answer( if self._dead: raise AcpRuntimeDead("runtime is dead") + folder = await self._admit_prompt(method, params) req_id = self._next_id self._next_id += 1 projection = getattr(self, "_native_skill_projection", None) @@ -443,6 +499,7 @@ async def send_request_for_answer( # stall marks the runtime dead and raises AcpRuntimeStdinStalled (an # AcpRuntimeDead), caught below so the pending future is retrieved or # cancelled rather than left unretrieved. + self._record_prompt(method, req_id, folder) await self._write_request_bounded(data, req_id, method) except AcpRuntimeDead: # Reached two ways, both of which have already resolved the future @@ -591,6 +648,7 @@ async def _send_and_await( if self._dead: raise AcpRuntimeDead("runtime is dead") + folder = await self._admit_prompt(method, params) req_id = self._next_id self._next_id += 1 ahead = self._one_at_a_time_ahead(method, req_id) @@ -611,6 +669,7 @@ async def _send_and_await( # drain under the shared write lock and must not park it against # a flow-control-paused kiro-cli. A stall raises AcpRuntimeStdinStalled # (an AcpRuntimeDead), caught below to retrieve/cancel the future. + self._record_prompt(method, req_id, folder) await self._write_request_bounded(data, req_id, method) except AcpRuntimeDead: # Reached through _refuse_write_if_dead (a sibling stall marked the diff --git a/src/kiro_crew/dashboard/chat_runner.py b/src/kiro_crew/dashboard/chat_runner.py index 3642a6eb93d..29cb7165627 100644 --- a/src/kiro_crew/dashboard/chat_runner.py +++ b/src/kiro_crew/dashboard/chat_runner.py @@ -31,6 +31,7 @@ context_omissions, mcp_apps_render, model_registry, + repo_checkout_guard, resource_status, runtime_death, session_directive, diff --git a/src/kiro_crew/dashboard/chat_turn/slash_commands.py b/src/kiro_crew/dashboard/chat_turn/slash_commands.py index a53907874d3..677a72c6576 100644 --- a/src/kiro_crew/dashboard/chat_turn/slash_commands.py +++ b/src/kiro_crew/dashboard/chat_turn/slash_commands.py @@ -486,7 +486,11 @@ async def _turn_slash_gate(turn: _ChatTurn) -> _Flow | None: # /prompts get — invoke the prompt in this chat name = args[2] # Off the loop for the same reason as the @mention path in _turn_admit. - expanded, status = await _expand_prompt_mention_off_loop(f"@{name}", state, slot) + # Read against the project recorded at entry, which the checkout wait + # covered: a pick made since may name a repository mid-switch. + expanded, status = await _expand_prompt_mention_off_loop( + f"@{name}", state, slot, project=str(turn._checkout_project or "") + ) if status == "ok": sel().log_tool_invocation( session_key="", @@ -563,8 +567,11 @@ async def _turn_slash_gate(turn: _ChatTurn) -> _Flow | None: # prompt_source_roots() with rglob + file reads; keep it off the # event loop so a slow/network-backed root can't stall the gateway. # Pass THIS slot's project so its local prompts are listed per-slot - # (fail-closed to global-only when the slot has no project). - project_dir = Path(slot.project) if slot.project else None + # (fail-closed to global-only when the slot has no project). The + # project recorded at entry, which the checkout wait covered, not + # the live field a mid-turn pick may have moved. + _checkout_project = turn._checkout_project + project_dir = Path(_checkout_project) if _checkout_project else None prompts = await asyncio.to_thread(_list_aim_prompts, project_dir) except Exception: prompts = [] diff --git a/src/kiro_crew/dashboard/chat_turn/turn_admission.py b/src/kiro_crew/dashboard/chat_turn/turn_admission.py index b112d9166c9..51fb8038e5c 100644 --- a/src/kiro_crew/dashboard/chat_turn/turn_admission.py +++ b/src/kiro_crew/dashboard/chat_turn/turn_admission.py @@ -24,6 +24,7 @@ from kiro_crew.quick_prompts import QUICK_PROMPTS if TYPE_CHECKING: + from kiro_crew import repo_checkout_guard from kiro_crew.dashboard.chat_runner import ( ACP_BACKEND_KIRO, MAX_PROMPT_BYTES, @@ -311,6 +312,17 @@ async def _turn_admit(turn: _ChatTurn) -> _Flow | None: # lives OUTSIDE it and is not swallowed by the resolve except. _app_agent_unresolved = False + # The branch switch's second ordering point. Between here and the claim + # below, where this turn's session will run is the allocator's choice: + # the project may be repicked before the binding is captured, and a + # resumed conversation's stored folder or a prepared member project can + # stand in for it. So the turn is marked unplaced first -- a switch's + # busy check then refuses in every repository -- and waits out any + # checkout already in flight, wherever it is. The claim records the + # folder the session reports and clears the mark. + slot._checkout_turn_unplaced = True + await repo_checkout_guard.wait_for_any_checkout(waiter=f"session {slot.key}") + # Serialize this turn's binding capture and session registration # against the slot-switch transaction of every alias on this session # (the same ``slot_switch_session_lock`` the switch handlers hold @@ -1070,6 +1082,21 @@ async def _claim_session(wait_if_busy: bool) -> tuple[Any, bool, bool]: from kiro_crew.skill_scope_pin import pin_session_skill_spec pin_session_skill_spec(session_key, _pinned_spec) + # The folder this session actually runs in, for the branch switch's busy + # check (see the unplaced mark above). Recorded and the mark cleared with + # no await between them; a provider that reports no folder leaves the + # turn unplaced, so switches stay refused while it runs. The wait sits + # after ``_acquired`` so a turn stopped during it still releases the + # session it holds. + _session_folder = str(getattr(client, "cwd", "") or "") + slot._checkout_turn_projects = ( + *slot._checkout_turn_projects, + str(selected_binding[1] or ""), + _session_folder, + ) + if _session_folder: + slot._checkout_turn_unplaced = False + await repo_checkout_guard.wait_for_checkout(_session_folder, waiter=f"session {slot.key}") # The acquired provider is the final authority for harness-specific slash # gates. A dead live row can be replaced during the get_or_create call # above. The positive capability fails closed for unknown adapters and diff --git a/src/kiro_crew/dashboard/chat_turn/turn_entry.py b/src/kiro_crew/dashboard/chat_turn/turn_entry.py index 45ebefd2550..cf1b12f0d42 100644 --- a/src/kiro_crew/dashboard/chat_turn/turn_entry.py +++ b/src/kiro_crew/dashboard/chat_turn/turn_entry.py @@ -19,6 +19,7 @@ from kiro_crew.start_priority import person_priority if TYPE_CHECKING: + from kiro_crew import repo_checkout_guard from kiro_crew.dashboard.chat_runner import ( ReplayFamily, StartPriority, @@ -261,6 +262,22 @@ async def _turn_open(turn: _ChatTurn) -> _Flow | None: _session_stop_gen_at_entry = _session_stop_generation(turn) turn._session_stop_gen_at_entry = _session_stop_gen_at_entry + # Chokepoint for the branch switch's ordering: a turn must not read project + # files on one branch and write them back on another. This is the first + # await of the body, before any prompt expansion reads a file, and the slot + # already publishes this turn, so a switch either refuses on seeing it or + # holds a reservation this waits out (see repo_checkout_guard). It sits + # after both Stop-generation snapshots and the replay-row freeze: a Stop + # pressed during the wait must still read as pressed at dispatch, and a + # row appended during it must not become this turn's trigger. + # The project is recorded first, so a switch's busy check keeps seeing this + # turn in it even if another project is picked while the turn runs. + _checkout_project = getattr(slot, "project", None) + turn._checkout_project = _checkout_project + slot._checkout_turn_projects = (str(_checkout_project or ""),) + slot._checkout_turn_unplaced = False + await repo_checkout_guard.wait_for_checkout(_checkout_project, waiter=f"session {slot.key}") + # Who caused this turn, for the session ledger. Bound COMPLETELY here, before # anything that can fail: ``_queue_recovery`` stamps this value, and the # handlers that call it also serve failures raised during pre-turn setup -- diff --git a/src/kiro_crew/dashboard/chat_turn/turn_locals.py b/src/kiro_crew/dashboard/chat_turn/turn_locals.py index 70dc387f1e9..0dcaa2481f0 100644 --- a/src/kiro_crew/dashboard/chat_turn/turn_locals.py +++ b/src/kiro_crew/dashboard/chat_turn/turn_locals.py @@ -100,6 +100,7 @@ class _ChatTurn: _batch_cascade_steered: "bool" _builtin_identity_trusted: "bool" _child_lf_warning: "str" + _checkout_project: "str | None" _compact_after_turn_noticed: "bool" _compaction_completed: "bool" _compaction_dropped_leak: "bool" diff --git a/src/kiro_crew/dashboard/handlers/git_branches.py b/src/kiro_crew/dashboard/handlers/git_branches.py new file mode 100644 index 00000000000..161ddb1b22e --- /dev/null +++ b/src/kiro_crew/dashboard/handlers/git_branches.py @@ -0,0 +1,945 @@ +"""Branch listing and switching for the dashboard Git panel. + +Two endpoints back the panel's branch switcher: + +* ``GET /api/project/git/branches?path=`` lists the repository's local + branches (most recently committed first) and the remote-tracking branches that + have no local counterpart, plus which one is checked out. Rows carry the + branch name, commit date/author/subject and local tracking counts. +* ``POST /api/project/git/switch`` with ``{path, branch, create?, track?}`` + checks out an existing local branch, creates a new branch at ``HEAD`` + (``create: true``), or creates a local branch tracking a remote one + (``track: "/"``). Success returns ``{ok, branch}``; an + existing branch already checked out returns the same body without a switch. + +Trust model +----------- +This is the same boundary ``POST /api/worktree/create`` draws for the one other +dashboard route that changes a repository, and it reuses that module's +machinery rather than restating it: + +* ``path`` must name, or sit inside, a directory an existing chat slot is scoped + to, and so must the git toplevel it resolves to (a subdirectory grant cannot + reach the parent repository). Only the server-held root is used as a path. +* git runs through :func:`kiro_crew.dashboard.handlers.worktree._run_git`: the + ``sandboxed_spawn_argv`` chokepoint in strict mode, an argv list with no shell, + resource limits, a timeout, ``core.hooksPath`` pointed at ``os.devnull`` (no + ``post-checkout`` hook runs) and ``core.fsmonitor`` off. +* A checkout runs ``filter..smudge``/``.process`` drivers, which ``-c`` + cannot disable by name, so a repository whose own config declares one is + refused before the switch (:func:`~kiro_crew.dashboard.handlers.worktree._checkout_filter`). + The listing still answers there, carrying ``switchBlocked: "filter"`` so the + panel can say why switching is off instead of failing on click. + Every switch passes ``--no-recurse-submodules`` so submodule worktrees and + their repository-scoped content filters are not reached, regardless of the + superproject's ``submodule.recurse`` setting. +* The switch rewrites files in the working tree, so it takes the same OWNER gate + as ``/api/file-write``; listing takes the dashboard-caller gate the worktree + route uses. +* Branch names come back through ``redact`` like every other repository string + the dashboard renders. A name the redactor alters cannot be switched to by + echoing it back, so it is marked ``switchable: false`` rather than offered as a + row that fails on click. + +``git switch`` itself refuses a switch that would overwrite uncommitted or +untracked changes, so the working tree is never discarded; that refusal is +reported as ``409 git_switch_dirty``. Non-conflicting local changes carry over +to the new branch, as they do on the command line. +""" + +from __future__ import annotations + +import asyncio +import functools +import logging +import os +import re +import subprocess +from typing import TYPE_CHECKING + +from aiohttp import web + +from kiro_crew import repo_checkout_guard +from kiro_crew.dashboard import repo_checkout_guard as checkout_readers +from kiro_crew.dashboard.chat_handlers import deny_non_dashboard_caller +from kiro_crew.dashboard.handlers._shared import read_bounded_json, require_owner_dashboard_request +from kiro_crew.dashboard.handlers.files import _is_not_a_repo_verdict, _run_git_bounded +from kiro_crew.dashboard.handlers.worktree import ( + SandboxUnavailable, + _allowed_repo_roots, + _checkout_filter, + _git_no_repo_code, + _match_allowed_root, + _repo_lock, + _resolve_commit, + _run_git, +) +from kiro_crew.platform import redact_via_context as redact +from kiro_crew.security import is_sensitive_path +from kiro_crew.sel import sel +from kiro_crew.validation import is_valid_followup_branch + +if TYPE_CHECKING: + from kiro_crew.dashboard.state import _ChatSlot + +logger = logging.getLogger(__name__) + +#: Rows per section. A repository can carry thousands of branches; the panel is +#: a picker, not an inventory, and the list is most-recent-first, so the cap +#: drops the stalest rows and the response says so with ``truncated``. +MAX_BRANCH_ROWS = 200 + +#: Longest branch name the switch route accepts. Git's own limit is the +#: filesystem's (a loose ref is a file), so this only bounds the request. +MAX_BRANCH_NAME = 200 + +#: Longest commit subject / author name a row keeps. Applied after redaction, +#: so a cut never leaves part of a secret readable. +MAX_ROW_TEXT = 300 + +#: Bytes of ``for-each-ref`` output one listing may capture. 200 rows of +#: ordinary refs fit with room to spare; a repository whose refs point at +#: commits with enormous subject lines overflows it, and the listing answers +#: 503 rather than buffering them. +MAX_LIST_OUTPUT = 2 * 1024 * 1024 + +#: git's diagnostics are matched as English text (``_SWITCH_FAILURES``, the +#: not-a-repository verdict), so every git call here runs in the C locale. +_C_LOCALE = {"LC_ALL": "C", "LANGUAGE": "C"} + +_SEP = "\x1f" +_FORMAT = _SEP.join( + ( + "%(refname)", + "%(committerdate:iso-strict)", + "%(authorname)", + "%(contents:subject)", + "%(upstream:track,nobracket)", + "%(HEAD)", + ) +) +# for-each-ref interpolates %xx hex escapes, so the separator is passed as one. +_FORMAT_ARG = _FORMAT.replace(_SEP, "%1f") + +_TRACK_RE = re.compile(r"(ahead|behind) (\d+)") + +_SANDBOX_REFUSAL = ( + "This host has no OS sandbox backend, so Kiro Crew will not run git for you. " + "Switch branches from a terminal." +) + + +class _Refusal(Exception): + """A request that resolves to a JSON error body instead of a repository.""" + + def __init__(self, body: dict, status: int) -> None: + super().__init__(body.get("error", "")) + self.body = body + self.status = status + + +def _error(message: str, code: str, status: int) -> web.Response: + return web.json_response({"error": message, "code": code}, status=status) + + +def _closest_allowed_root(candidate: str, roots: list[str]) -> str | None: + """The allow-listed root nearest to ``candidate``: the one it names, else the + deepest one it sits inside. + + ``_match_allowed_root`` returns the FIRST root that names or contains the + path, so with one chat's project in a parent repository and another's in a + repository nested inside it, a request for the nested project would resolve + to the parent and switch the parent's branch. The value returned is still + one from ``roots``, never the caller's string. + """ + matches = [root for root in roots if _match_allowed_root(candidate, [root]) is not None] + if not matches: + return None + return max(matches, key=lambda root: len(os.path.normcase(root).rstrip(os.sep))) + + +def _exact_allowed_root(candidate: str, roots: list[str]) -> str | None: + """The allow-listed root ``candidate`` names exactly, else ``None``. + + The switch writes to a repository, so it acts only on the chat project the + picker named. Matching by containment would let a request for a nested + repository that is not any chat's project (say the chat moved to the parent + repository while the request was in flight) resolve to that parent and + check it out instead. Refusing is right there: the picker that sent it does + not describe any chat. Returns the value FROM ``roots``. + """ + probe = os.path.normcase(candidate).rstrip(os.sep) or os.sep + for root in roots: + if os.path.normcase(root).rstrip(os.sep) == probe: + return root + return None + + +async def _resolve_repo_root( + request: web.Request, raw: object, operation: str, *, exact: bool = False +) -> str: + """Map the submitted project path onto a server-held git toplevel. + + Raises :class:`_Refusal` for every outcome other than a usable root. Mirrors + ``api_worktree_create``'s order: allow-list on the normalized string first, + then the filesystem checks on the server-held value, then the toplevel + re-check so resolving upward cannot leave the granted tree. ``exact`` (the + switch) accepts only a path that IS a chat project + (:func:`_exact_allowed_root`); the read-only listing also accepts a folder + inside one. + """ + caller = str(request.get("user") or "dashboard") + if not isinstance(raw, str) or not raw.strip(): + raise _Refusal({"error": "path required", "code": "path_required"}, 400) + roots = await asyncio.to_thread(_allowed_repo_roots, request.app.get("state")) + submitted = os.path.normpath(os.path.expanduser(raw.strip())) + project = ( + _exact_allowed_root(submitted, roots) if exact else _closest_allowed_root(submitted, roots) + ) + if project is None: + sel().log_api_access( + caller=caller, + operation=operation, + outcome="denied", + resources=f"path={submitted[:300]}", + error="outside slot project directories", + ) + raise _Refusal({"error": "Unknown project directory", "code": "unknown_project_dir"}, 403) + if await asyncio.to_thread(is_sensitive_path, project): + sel().log_api_access( + caller=caller, + operation=operation, + outcome="denied", + resources=f"path={project}", + error="sensitive path", + ) + raise _Refusal({"error": "Access denied", "code": "access_denied"}, 403) + if not await asyncio.to_thread(os.path.isdir, project): + raise _Refusal({"error": "Not a git repository", "code": "not_a_repository"}, 404) + try: + root = await asyncio.to_thread(_toplevel, project) + except SandboxUnavailable as exc: + logger.warning("%s: sandbox unavailable: %s", operation, exc) + raise _Refusal({"error": _SANDBOX_REFUSAL, "code": "git_sandbox_unavailable"}, 503) from exc + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("%s: git toplevel probe failed: %s", operation, exc) + raise _Refusal({"error": "git is unavailable", "code": "git_unavailable"}, 503) from exc + if root is None: + raise _Refusal({"error": "Not a git repository", "code": "not_a_repository"}, 404) + if not root: + # git ran and refused for another reason (dubious ownership, a corrupt + # .git): an outage, not proof the directory holds no repository. + raise _Refusal( + { + "error": "Couldn't read the repository's branches.", + "code": "git_branches_unavailable", + }, + 503, + ) + if _match_allowed_root(root, roots) is None or await asyncio.to_thread(is_sensitive_path, root): + sel().log_api_access( + caller=caller, + operation=operation, + outcome="denied", + resources=f"root={root}", + error="git toplevel outside slot project directories", + ) + raise _Refusal( + { + "error": ( + "The repository root is outside this session's project directory, " + "so branches can't be switched from here." + ), + "code": "repo_root_outside_project", + }, + 403, + ) + return root + + +def _toplevel(project: str) -> str | None: + """Work-tree root of ``project``; None when git says it is no repository. + + Returns ``""`` for any other failed probe, which the caller reports as an + outage rather than an absence. + """ + probe = _run_git(["rev-parse", "--show-toplevel"], project, c_locale=True) + if probe.returncode != 0: + return None if _is_not_a_repo_verdict(probe.stderr or "") else "" + top = probe.stdout.strip() + return os.path.realpath(top) if top else "" + + +def _parse_track(track: str) -> tuple[int, int]: + ahead = behind = 0 + for kind, count in _TRACK_RE.findall(track): + if kind == "ahead": + ahead = int(count) + else: + behind = int(count) + return ahead, behind + + +def _list_refs(root: str, namespace: str) -> tuple[list[dict], bool] | None: + """Rows for ``refs/heads`` or ``refs/remotes``, newest first; None on failure.""" + limit = MAX_BRANCH_ROWS + 1 + rc, stdout, overflow = _run_git_bounded( + [ + "git", + *_git_no_repo_code(), + "for-each-ref", + "--sort=-committerdate", + f"--count={limit}", + f"--format={_FORMAT_ARG}", + namespace, + ], + cwd=root, + env={**os.environ, **_C_LOCALE}, + timeout=10, + cap=MAX_LIST_OUTPUT, + ) + if rc != 0 or overflow: + return None + prefix = namespace.rstrip("/") + "/" + rows: list[dict] = [] + lines = stdout.splitlines() + for line in lines: + parts = line.split(_SEP) + if len(parts) != 6: + continue + refname, date, author, subject, track, head = parts + if not refname.startswith(prefix): + continue + name = refname[len(prefix) :] + # ``refs/remotes//HEAD`` is a symref naming the remote's default + # branch, not a branch of its own. + if namespace == "refs/remotes" and name.endswith("/HEAD"): + continue + row: dict = { + "name": name, + "date": date, + "author": author, + "subject": subject, + } + if namespace == "refs/heads": + row["current"] = head == "*" + ahead, behind = _parse_track(track) + if ahead: + row["ahead"] = ahead + if behind: + row["behind"] = behind + rows.append(row) + # git stops at ``limit`` lines and a skipped ``/HEAD`` symref can use + # one of them, so a capped answer may hide a row even at <= 200 rows. + truncated = len(rows) > MAX_BRANCH_ROWS or len(lines) >= limit + return rows[:MAX_BRANCH_ROWS], truncated + + +def _all_local_names(root: str) -> set[str] | None: + """Every local branch's short name, uncapped; None on failure. + + Only read when the local listing was capped: a remote row's local twin can + be older than the newest ``MAX_BRANCH_ROWS`` and so missing from it. + """ + rc, stdout, overflow = _run_git_bounded( + [ + "git", + *_git_no_repo_code(), + "for-each-ref", + "--format=%(refname:lstrip=2)", + "refs/heads", + ], + cwd=root, + env={**os.environ, **_C_LOCALE}, + timeout=10, + cap=MAX_LIST_OUTPUT, + ) + if rc != 0 or overflow: + return None + return {line for line in stdout.splitlines() if line} + + +def _branches_sync(root: str) -> tuple[dict, int]: + """Blocking half of the listing route. Returns ``(json_body, http_status)``.""" + current_proc = _run_git(["branch", "--show-current"], root, c_locale=True) + if current_proc.returncode != 0: + return ( + { + "error": "Couldn't read the repository's branches.", + "code": "git_branches_unavailable", + }, + 503, + ) + current = (current_proc.stdout or "").strip() + local = _list_refs(root, "refs/heads") + remote = _list_refs(root, "refs/remotes") + if local is None or remote is None: + return ( + { + "error": "Couldn't read the repository's branches.", + "code": "git_branches_unavailable", + }, + 503, + ) + local_rows, local_truncated = local + remote_rows, remote_truncated = remote + local_names = {r["name"] for r in local_rows} + # A remote branch that already has a local branch of the same short name is + # reached through that local row; listing both offers two rows that switch + # to the same place, and the remote one would always fail (the local name + # exists). A capped local list can miss that twin, so read every name. + if local_truncated: + local_names |= _all_local_names(root) or set() + remote_rows = [r for r in remote_rows if r["name"].split("/", 1)[-1] not in local_names] + body: dict = { + "repo": True, + "current": current or None, + "local": local_rows, + "remote": remote_rows, + } + if not current: + head = _run_git(["rev-parse", "--short", "HEAD"], root, c_locale=True) + if head.returncode == 0 and head.stdout.strip(): + body["detached"] = True + body["head"] = head.stdout.strip() + if local_truncated or remote_truncated: + body["truncated"] = True + if _checkout_filter(root): + body["switchBlocked"] = "filter" + return body, 200 + + +def _clip(text: str, limit: int) -> str: + return text if len(text) <= limit else text[: limit - 1] + "\u2026" + + +def _redact_rows(rows: list[dict], *, remote: bool = False) -> None: + """Redact every display field, then bound it; redacting first means a cut + never leaves part of a secret readable.""" + for row in rows: + original = row["name"] + row["name"] = _clip(redact(original), MAX_BRANCH_NAME) + # A redacted or clipped name cannot be echoed back to /switch, so the + # row is shown but not offered as a target. + row["switchable"] = row["name"] == original + if remote: + local_name = original.split("/", 1)[-1] + row["switchable"] = ( + row["switchable"] + and is_valid_followup_branch(local_name) + and len(local_name) <= MAX_BRANCH_NAME + ) + row["author"] = _clip(redact(row["author"]), MAX_ROW_TEXT) + row["subject"] = _clip(redact(row["subject"]), MAX_ROW_TEXT) + + +async def api_project_git_branches(request: web.Request) -> web.Response: + """GET ``/api/project/git/branches?path=``. + + Response: ``{repo, current, detached?, head?, local: [...], remote: [...], + truncated?, switchBlocked?}``. Each local row carries ``name, date, author, + subject, current, switchable`` and optional tracking counts ``ahead?, behind?``; + remote rows carry the same commit fields with a ``/`` name. + """ + denied = deny_non_dashboard_caller(request, "project_git_branches") + if denied is not None: + return denied + try: + root = await _resolve_repo_root( + request, request.query.get("path", ""), "project_git_branches" + ) + except _Refusal as refusal: + if refusal.body.get("code") == "not_a_repository": + return web.json_response({"repo": False, "local": [], "remote": []}) + return _error(refusal.body["error"], refusal.body["code"], refusal.status) + sel().log_api_access( + caller=str(request.get("user") or "dashboard"), + operation="project_git_branches", + outcome="allowed", + resources=root, + ) + try: + body, status = await asyncio.to_thread(_branches_sync, root) + except SandboxUnavailable as exc: + logger.warning("project_git_branches: sandbox unavailable: %s", exc) + return _error(_SANDBOX_REFUSAL, "git_sandbox_unavailable", 503) + except subprocess.TimeoutExpired: + return _error("git timed out", "git_timeout", 504) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("project_git_branches failed: %s", exc) + return _error("Couldn't read the repository's branches.", "git_branches_unavailable", 503) + if status != 200: + return _error(body["error"], body["code"], status) + _redact_rows(body["local"]) + _redact_rows(body["remote"], remote=True) + if body.get("current"): + body["current"] = redact(body["current"]) + if body.get("head"): + body["head"] = redact(body["head"]) + return web.json_response(body) + + +def _is_plain_ref_name(name: str) -> bool: + """Whether ``name`` can only be read by git as a ref name, never an option or shorthand. + + Excludes a leading ``-`` (an option, and ``-`` alone is "previous branch"), + ``@{`` (reflog shorthand such as ``@{-1}``), and any whitespace or control + character. Existence and git's own ref grammar are checked separately. + """ + if not name or len(name) > MAX_BRANCH_NAME or name.startswith("-") or "@{" in name: + return False + return not any(ch.isspace() or ord(ch) < 0x20 or ord(ch) == 0x7F for ch in name) + + +# (stderr fragment, code, http status, user-facing message). Matched in order +# against git's English diagnostics; anything else falls through to the generic +# failure with git's own first line as ``detail``. The untracked entry comes +# first: git's untracked refusal also contains the uncommitted entry's fragment. +_SWITCH_FAILURES: tuple[tuple[str, str, int, str], ...] = ( + ( + "untracked working tree files would be overwritten", + "git_switch_dirty", + 409, + "Untracked files would be overwritten by this switch. Move or commit them first.", + ), + ( + "would be overwritten by checkout", + "git_switch_dirty", + 409, + "Your uncommitted changes would be overwritten by this switch. Commit or stash them first.", + ), + ( + "already used by worktree", + "git_branch_in_worktree", + 409, + "That branch is already checked out in another worktree.", + ), + ( + "is already checked out at", + "git_branch_in_worktree", + 409, + "That branch is already checked out in another worktree.", + ), + ("already exists", "git_branch_exists", 409, "A branch with that name already exists."), + ( + "you need to resolve your current index first", + "git_operation_in_progress", + 409, + "Finish or abort the merge, rebase or cherry-pick in progress first.", + ), + ( + "in the middle of", + "git_operation_in_progress", + 409, + "Finish or abort the merge, rebase or cherry-pick in progress first.", + ), + ( + "cannot switch branch while", + "git_operation_in_progress", + 409, + "Finish or abort the merge, rebase or cherry-pick in progress first.", + ), +) + + +def _slot_folder_snapshot(state: object) -> list[tuple[_ChatSlot, list[str], bool]]: + """Each chat slot with the folders its work can touch and whether they are known. + + Taken on the event loop (see ``dashboard.repo_checkout_guard.slot_folders``); only the + path matching against it runs in a worker thread. + """ + slots = getattr(state, "_slots", None) or {} + return [ + (slot, *checkout_readers.slot_folders(state, slot)) + for slot in list(getattr(slots, "values", list)()) + ] + + +def _match_slots(snapshot: list[tuple[_ChatSlot, list[str], bool]], root: str) -> list[_ChatSlot]: + return [ + slot + for slot, folders, _placed in snapshot + if any(_shares_tree(folder.strip(), root) for folder in folders if folder.strip()) + ] + + +def _slots_in_repo(state: object, root: str) -> list[_ChatSlot]: + """Chat slots whose work shares a working tree with ``root``. + + A slot scoped to the repository root, to a folder inside it, or to a folder + that contains it can all read and write files a checkout of ``root`` rewrites. + A running slot is matched on every folder its turn works in as well as its + current ``project`` (see ``dashboard.repo_checkout_guard.slot_folders``): a project + picked mid-turn replaces ``project`` at once, but the turn's session keeps + working where it started until the turn ends. + """ + return _match_slots(_slot_folder_snapshot(state), root) + + +def _shares_tree(project: str, root: str) -> bool: + """Whether ``project`` is ``root``, lies inside it, or contains it.""" + resolved = os.path.realpath(project) + try: + common = os.path.commonpath([resolved, root]) + except ValueError: # different drives on Windows + return False + return common in (resolved, root) + + +async def _repo_has_running_work(state: object, root: str) -> bool: + """Whether any work in ``root`` is in flight. + + That is a turn or pending sub-agents on a session working in ``root``, a + running sub-agent whose own folder, or an ancestor run's, lies in ``root``, + or a workflow step whose session runs there. + A checkout rewrites files under every such session, not only the one whose + picker asked, so the client-side guard alone cannot cover a second tab, a + cron turn on another slot, or a sub-agent still running for a finished turn. + Fails closed: a sub-agent probe that errors counts as running work, and so + does a running turn whose session has not reported its folder yet. + """ + from kiro_crew.dashboard.chat_folders import _subagent_work_pending + from kiro_crew.dashboard.chat_utils import effective_session_key + + snapshot = _slot_folder_snapshot(state) + if any(not placed for _slot, _folders, placed in snapshot): + return True + # Workflow steps open their own sessions, outside slots and the sub-agent + # manager; read on the loop for the same reason as the slot snapshot. + work_folders, work_placed = repo_checkout_guard.tracked_work_folders() + if not work_placed: + return True + if work_folders: + if await asyncio.to_thread(repo_checkout_guard.folders_overlap, work_folders, root): + return True + slots = await asyncio.to_thread(_match_slots, snapshot, root) + subagents = getattr(state, "subagents", None) + for slot in slots: + if getattr(slot, "running", False): + return True + if subagents is None: + continue + try: + if await _subagent_work_pending(subagents, effective_session_key(slot)): + return True + except Exception: # noqa: BLE001 - any probe failure is treated as busy + return True + if subagents is None: + return False + # A run started from a chat outside the repository can still work inside it + # through its own ``cwd`` (or an ancestor run's), which the per-slot probe + # above cannot see. Queued runs are not counted: they wait at their start. + # A finished run counts until its teardown is over (live_subagent_runs). + try: + by_key = checkout_readers.runs_by_key(state) + live, known = checkout_readers.live_subagent_runs(subagents) + if not known: + return True + folders = [ + folder + for run in live + for folder in checkout_readers.subagent_folders(state, run, by_key) + ] + except Exception: # noqa: BLE001 - an unreadable registry or lineage is treated as busy + return True + if not folders: + return False + return await asyncio.to_thread(repo_checkout_guard.folders_overlap, folders, root) + + +def _first_line(text: str) -> str: + for line in (text or "").splitlines(): + line = line.strip() + if line: + return redact(line)[:300] + return "" + + +#: How many conflicting paths a dirty-tree refusal names; the rest are counted. +MAX_DIRTY_FILES = 5 + + +def _overwritten_files(stderr: str) -> tuple[list[str], int]: + """The paths git lists under "... would be overwritten by checkout:". + + git prints each one on its own tab-indented line after that header, so + the refusal can say which files block the switch. Returns at most + ``MAX_DIRTY_FILES`` redacted, clipped paths and how many more there were. + """ + files: list[str] = [] + listing = False + for line in (stderr or "").splitlines(): + if "would be overwritten by checkout" in line.lower(): + listing = True + continue + if listing: + if not line.startswith("\t"): + listing = False + continue + path = line.strip() + if path: + files.append(path) + shown = [_clip(redact(p), MAX_ROW_TEXT) for p in files[:MAX_DIRTY_FILES]] + return shown, max(0, len(files) - MAX_DIRTY_FILES) + + +def _switch_sync(root: str, branch: str, create: bool, track: str) -> tuple[dict, int]: + """Blocking half of the switch route. Returns ``(json_body, http_status)``.""" + if _checkout_filter(root): + return ( + { + "error": ( + "This repository's Git config declares a content filter that git " + "would run on checkout, so switching is off here. Switch from a terminal." + ), + "code": "git_switch_filter_refused", + }, + 409, + ) + fmt = _run_git(["check-ref-format", f"refs/heads/{branch}"], root, c_locale=True) + if fmt.returncode != 0: + return ({"error": "Invalid branch name", "code": "invalid_branch"}, 400) + + if track: + if not _resolve_commit(root, f"refs/remotes/{track}"): + return ({"error": "Remote branch not found", "code": "git_branch_not_found"}, 404) + args = [ + "switch", + "--no-recurse-submodules", + "--no-overwrite-ignore", + "-c", + branch, + "--track", + f"refs/remotes/{track}", + ] + elif create: + if _resolve_commit(root, f"refs/heads/{branch}"): + return ( + {"error": "A branch with that name already exists.", "code": "git_branch_exists"}, + 409, + ) + args = [ + "switch", + "--no-recurse-submodules", + "--no-overwrite-ignore", + "--no-track", + "-c", + branch, + ] + else: + if not _resolve_commit(root, f"refs/heads/{branch}"): + return ({"error": "Branch not found", "code": "git_branch_not_found"}, 404) + current = _run_git(["symbolic-ref", "--quiet", "HEAD"], root, c_locale=True) + if current.returncode == 0 and (current.stdout or "").strip() == f"refs/heads/{branch}": + return ({"ok": True, "branch": branch}, 200) + # git's default lets a switch silently replace an ignored local file + # that the target branch tracks; refuse that like any other overwrite. + args = [ + "switch", + "--no-recurse-submodules", + "--no-overwrite-ignore", + "--no-guess", + "--", + branch, + ] + + proc = _run_git(args, root, c_locale=True) + if proc.returncode != 0: + stderr = (proc.stderr or "").lower() + for fragment, code, status, message in _SWITCH_FAILURES: + if fragment in stderr: + failure: dict = {"error": message, "code": code} + if code == "git_switch_dirty": + files, more = _overwritten_files(proc.stderr or "") + if files: + failure["files"] = files + failure["files_more"] = more + return (failure, status) + return ( + { + "error": "git couldn't switch branches.", + "code": "git_switch_failed", + "detail": _first_line(proc.stderr or proc.stdout or ""), + }, + 400, + ) + return ({"ok": True, "branch": branch}, 200) + + +def _audit_switch(caller: str, resources: str, outcome: str, error: str) -> None: + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome=outcome, + resources=resources, + error=error, + ) + + +def _audit_switch_outcome( + caller: str, resources: str, worker: asyncio.Future[tuple[dict, int]] +) -> None: + """Record what a finished checkout worker did, whether or not its request still waits.""" + if worker.cancelled(): + outcome, error = "error", "switch worker cancelled" + elif (exc := worker.exception()) is None: + payload, status = worker.result() + outcome = "allowed" if status == 200 else "error" + error = "" if status == 200 else str(payload.get("code", "")) + elif isinstance(exc, subprocess.TimeoutExpired): + outcome, error = "error", "git timeout" + elif isinstance(exc, SandboxUnavailable): + outcome, error = "denied", "sandbox backend unavailable" + else: + outcome, error = "error", "git switch failed" + try: + _audit_switch(caller, resources, outcome, error) + except Exception: # a done callback has no caller to raise to + logger.exception("project_git_switch: could not record the switch outcome") + + +async def api_project_git_switch(request: web.Request) -> web.Response: + """POST ``/api/project/git/switch`` with ``{path, branch, create?, track?}``. + + ``create: true`` makes a new branch at ``HEAD``; ``track: "/"`` + makes ``branch`` as a local branch tracking that remote branch; neither + switches to an existing local branch. Returns ``{ok, branch}``. + """ + caller = str(request.get("user") or "dashboard") + owner_denied = await require_owner_dashboard_request(request, "project_git_switch") + if owner_denied is not None: + return owner_denied + body, body_err = await read_bounded_json(request) + if body_err is not None: + return body_err + assert body is not None + + branch = body.get("branch") + create = body.get("create", False) + track = body.get("track", "") + if not isinstance(branch, str) or not isinstance(create, bool) or not isinstance(track, str): + return _error("invalid input", "invalid_input", 400) + branch, track = branch.strip(), track.strip() + if create and track: + return _error("create and track are mutually exclusive", "invalid_input", 400) + # A new name must satisfy the stricter grammar the follow-up card's branch + # field uses; an existing one only has to be unambiguous as a ref name, + # since it already exists and git accepted it once. + name_ok = ( + is_valid_followup_branch(branch) and len(branch) <= MAX_BRANCH_NAME + if (create or track) + else _is_plain_ref_name(branch) + ) + if not name_ok or (track and not _is_plain_ref_name(track)): + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="denied", + resources=f"branch={branch[:120]}", + error="invalid branch name", + ) + return _error("Invalid branch name", "invalid_branch", 400) + + try: + root = await _resolve_repo_root(request, body.get("path"), "project_git_switch", exact=True) + except _Refusal as refusal: + return _error(refusal.body["error"], refusal.body["code"], refusal.status) + + resources = f"root={root} branch={branch}" + (f" track={track}" if track else "") + worker: asyncio.Future[tuple[dict, int]] | None = None + try: + async with _repo_lock(root): + # Reserved before the busy check awaits anything, so a turn that + # starts from here on waits at its entry for the checkout instead of + # slipping in after the check (see repo_checkout_guard). + if not repo_checkout_guard.reserve(root): + # A previous switch's worker outlived its request and still runs. + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="denied", + resources=resources, + error="a switch in this repository is still running", + ) + return _error( + "A branch switch in this repository is still running. " + "Wait for it to finish, then switch.", + "git_switch_session_busy", + 409, + ) + try: + if await _repo_has_running_work(request.app.get("state"), root): + sel().log_api_access( + caller=caller, + operation="project_git_switch", + outcome="denied", + resources=resources, + error="a session in this repository is running", + ) + return _error( + "A session working in this repository is running. " + "Wait for it to finish, then switch.", + "git_switch_session_busy", + 409, + ) + worker = asyncio.ensure_future( + asyncio.to_thread(_switch_sync, root, branch, create, track) + ) + repo_checkout_guard.release_when_done(root, worker) + # Audited from the worker's own completion, so a switch that + # finishes after its request was cancelled is still recorded. + # Registered before the shield, so it runs before this handler + # resumes. + worker.add_done_callback( + functools.partial(_audit_switch_outcome, caller, resources) + ) + try: + payload, status = await asyncio.shield(worker) + except asyncio.CancelledError: + # The request went away, but git is still rewriting files: + # hold the lock until it exits, then let the cancel through. + await asyncio.wait([worker]) + raise + finally: + if worker is None: + repo_checkout_guard.release(root) + except subprocess.TimeoutExpired: + if worker is None: + _audit_switch(caller, resources, "error", "git timeout") + return _error("git timed out", "git_timeout", 504) + except SandboxUnavailable as exc: + logger.warning("project_git_switch: sandbox unavailable: %s", exc) + if worker is None: + _audit_switch(caller, resources, "denied", "sandbox backend unavailable") + return _error(_SANDBOX_REFUSAL, "git_sandbox_unavailable", 503) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("project_git_switch failed: %s", exc) + return _error("git couldn't switch branches.", "git_switch_failed", 500) + + if status != 200: + # Each coded body stays a dict literal so the error-code ratchet can see + # its ``code``. ``files`` (dirty refusal) and ``detail`` (generic + # failure) come from different branches of _switch_sync, never both. + if payload.get("files"): + return web.json_response( + { + "error": payload["error"], + "code": payload["code"], + # Already redacted and clipped by _overwritten_files. + "files": payload["files"], + "files_more": payload.get("files_more", 0), + }, + status=status, + ) + if payload.get("detail"): + return web.json_response( + { + "error": payload["error"], + "code": payload["code"], + "detail": redact(payload["detail"]), + }, + status=status, + ) + return _error(payload["error"], payload["code"], status) + payload["branch"] = redact(payload["branch"]) + return web.json_response(payload) diff --git a/src/kiro_crew/dashboard/handlers/worktree.py b/src/kiro_crew/dashboard/handlers/worktree.py index 1a7108df47e..c5e77f70d3d 100644 --- a/src/kiro_crew/dashboard/handlers/worktree.py +++ b/src/kiro_crew/dashboard/handlers/worktree.py @@ -198,9 +198,13 @@ def _run_git( cwd: str, *, stdout_decoder: Callable[[bytes | str | None], str] = utf8_stdout, + c_locale: bool = False, ) -> subprocess.CompletedProcess[str]: """Run git with an argv list (never a shell) inside ``cwd``, OS-sandboxed. + ``c_locale`` runs git with ``LC_ALL=C`` and ``LANGUAGE=C``, for a caller + that classifies git's diagnostics and so needs them in English. + Routed through the ``sandboxed_spawn_argv`` chokepoint, matching ``git_coord.py``'s treatment of agent-influenced git: the repository is agent-selected and the branch is LLM-authored, so this spawn takes the OS @@ -243,6 +247,8 @@ def _run_git( ) except RuntimeError as exc: # no sandbox backend and no explicit opt-in raise SandboxUnavailable(str(exc)) from exc + if c_locale: + env.update(LC_ALL="C", LANGUAGE="C") env["GIT_TERMINAL_PROMPT"] = "0" try: # Bytes mode, decoded by utf8_stdout: text mode's universal-newline diff --git a/src/kiro_crew/dashboard/repo_checkout_guard.py b/src/kiro_crew/dashboard/repo_checkout_guard.py new file mode 100644 index 00000000000..f93637c9a31 --- /dev/null +++ b/src/kiro_crew/dashboard/repo_checkout_guard.py @@ -0,0 +1,201 @@ +"""The dashboard's readers for the checkout guard (:mod:`kiro_crew.repo_checkout_guard`). + +The registry itself (reservations, the waits, tracked work) lives below the +dashboard so the engines that register with it -- workflow steps, messaging +channel turns -- import no dashboard code. What stays here reads dashboard +objects: the folders a chat slot's work can touch (:func:`slot_folders`), and +the folders a sub-agent run works in, walked up its lineage to the chat at its +top (:func:`subagent_folders`, :func:`live_subagent_runs`). +""" + +from __future__ import annotations + +import re +from typing import Any + +from kiro_crew import repo_checkout_guard as _core +from kiro_crew.repo_checkout_guard import wait_for_any_checkout, wait_for_checkout + + +def slot_folders(state: Any, slot: Any) -> tuple[list[str], bool]: + """The folders a chat slot's work can touch, and whether all of them are known. + + An idle slot is judged by its ``project``. A running one also works where its + turn does, which ``project`` does not track: a project picked mid-turn, the + ``set_project`` tool and a member rebinding all write ``project`` while the + turn's session keeps running where it started, and a session kept alive across + a deferred reset, or resumed from a stored folder, runs in a folder no + ``project`` value named. So a running slot adds the folders its turn recorded + (``_ChatSlot._checkout_turn_projects``: the project at entry, the project its + session was allocated for, the folder that session reported) and the folder + the live session for its key reports now. + + The second value is False while the running turn is between starting to + allocate its session and that session reporting its folder, since where it + lands is the allocator's choice then, and when the session registry cannot be + read. Callers treat such a slot as working in every repository. + + Read on the event loop: the turn updates these fields without awaiting, so a + read here sees a consistent set where a read from a worker thread might not. + """ + folders = [str(getattr(slot, "project", "") or "")] + if not getattr(slot, "running", False): + return [folder for folder in folders if folder], True + folders.extend(str(f or "") for f in getattr(slot, "_checkout_turn_projects", ()) or ()) + placed = not getattr(slot, "_checkout_turn_unplaced", False) + sessions = getattr(state, "sessions", None) + if sessions is not None: + from kiro_crew.dashboard.chat_utils import effective_session_key + + try: + provider = sessions.get_provider(effective_session_key(slot)) + if provider is not None: + folders.append(str(getattr(provider, "cwd", "") or "")) + except Exception: # noqa: BLE001 - an unreadable registry leaves the folder unknown + placed = False + return [folder for folder in folders if folder], placed + + +def _slot_projects(state: Any, session_key: str) -> list[str]: + from kiro_crew.dashboard.chat_utils import effective_session_key + + found: list[str] = [] + slots = getattr(state, "_slots", None) or {} + for slot in list(getattr(slots, "values", list)()): + try: + if effective_session_key(slot) != session_key: + continue + except Exception: # noqa: BLE001 - a slot that cannot be keyed is not the parent + continue + # A root chat still placing its own session adds what is known; the + # chat itself is judged as a slot, so its unknown folder is not the run's. + found.extend(slot_folders(state, slot)[0]) + return found + + +class LineageUnknown(Exception): + """A run's lineage names a parent run whose record is gone (its card was dismissed).""" + + +#: A sub-agent run id as the manager mints it (lowercase hex). A ``_tasks`` key +#: of this shape, or one ending ``:recovery``, is a run's task; other keys +#: (``reject-``, ``lost-``, ``flush-``) are one-shot announcements. +_RUN_ID_RE = re.compile(r"\A[0-9a-f]{8,}\Z") + + +def live_subagent_runs(manager: Any) -> tuple[list[Any], bool]: + """Every sub-agent run whose native work may still be going, and whether all are known. + + ``running`` drops a run the moment ``done`` flips, but the run's ``finally`` + still caps its result and tears its session down after that, and a reap's + kill can outlive the run task. So a run counts until nothing of it is left: + not done, its task (or its cancel-recovery task) still live, its teardown + gate still registered, or its kill handles still retained. An id those + registries hold with no run record (a dashboard clear popped it + mid-teardown) cannot be placed, so the second value is False and the caller + counts every repository busy. A kill that FAILED leaves none of these + behind: the record and the tombstone name the leaked process, which the + next start ends; no in-memory signal says when such a process exits, so + that gap is left open here. A manager + without ``_agents`` (a test double) is read through ``running``. + """ + agents = getattr(manager, "_agents", None) + if not isinstance(agents, dict): + return list(getattr(manager, "running", None) or []), True + live_ids: set[str] = set() + known = True + for key, task in list((getattr(manager, "_tasks", None) or {}).items()): + if task.done(): + continue + run_id = str(key).removesuffix(":recovery") + if run_id in agents: + live_ids.add(run_id) + elif _RUN_ID_RE.match(run_id): + known = False + for registry in ("_teardown_gates", "_process_handles"): + for key in list(getattr(manager, registry, None) or {}): + if str(key) in agents: + live_ids.add(str(key)) + else: + known = False + runs = [ + run + for run_id, run in list(agents.items()) + if not getattr(run, "done", False) or run_id in live_ids + ] + return runs, known + + +def runs_by_key(state: Any) -> dict[str, Any]: + """The manager's runs keyed by the session key their children name as parent.""" + manager = getattr(state, "subagents", None) + by_key: dict[str, Any] = {} + for run in list(getattr(manager, "all_agents", None) or []): + key = str(getattr(run, "conversation_key", "") or f"subagent:{getattr(run, 'id', '')}") + by_key[key] = run + return by_key + + +def _own_folders(node: Any) -> list[str]: + """The folders a run itself works in, once they can be known. + + ``cwd`` is the spawn's override and ``_session_cwd`` the folder its provider + session actually started in, recorded once the session is acquired. A run + with neither has not been placed yet, and where it lands is the allocator's + choice (a member's workspace, a resumed conversation's stored folder, the + pool's or a per-session work folder), so :class:`LineageUnknown` is raised + for it rather than guessing. + """ + own = [ + str(getattr(node, "cwd", "") or ""), + str(getattr(node, "_session_cwd", "") or ""), + ] + if any(own): + return own + raise LineageUnknown(f"subagent:{getattr(node, 'id', '')}") + + +def subagent_folders(state: Any, info: Any, by_key: dict[str, Any] | None = None) -> list[str]: + """Every folder a sub-agent run works in: its own, its ancestors', its root chat's. + + Each run contributes the folders :func:`_own_folders` finds for it, and the + lineage is walked up through the manager's runs to the dashboard session at + its top, since a grandchild's parent is another run rather than a chat. + Raises :class:`LineageUnknown` when a ``subagent:`` parent has no record in + the manager, or when any run in the lineage has not been placed yet, since + the folders either would have contributed cannot be checked. + """ + if by_key is None: + by_key = runs_by_key(state) + folders: list[str] = [] + seen: set[int] = set() + node = info + while node is not None and id(node) not in seen: + seen.add(id(node)) + folders.extend(_own_folders(node)) + parent = str(getattr(node, "parent_session_key", "") or "") + if not parent: + break + node = by_key.get(parent) + if node is None: + if parent.startswith("subagent:"): + raise LineageUnknown(parent) + folders.extend(_slot_projects(state, parent)) + return [folder for folder in folders if folder] + + +async def wait_for_subagent_checkout(state: Any, info: Any) -> None: + """Wait out a checkout on any folder the sub-agent run works in. + + A run whose lineage cannot be resolved waits out every checkout in flight. + """ + if not _core.is_any_reserved(): + return + waiter = f"sub-agent {getattr(info, 'id', '')}" + try: + folders = subagent_folders(state, info) + except LineageUnknown: + await wait_for_any_checkout(waiter=waiter) + return + for folder in folders: + await wait_for_checkout(folder, waiter=waiter) diff --git a/src/kiro_crew/dashboard/routes/taskrunner.py b/src/kiro_crew/dashboard/routes/taskrunner.py index af8639681b4..a7578e828ec 100644 --- a/src/kiro_crew/dashboard/routes/taskrunner.py +++ b/src/kiro_crew/dashboard/routes/taskrunner.py @@ -14,6 +14,7 @@ from aiohttp import web from kiro_crew.dashboard import chat, handlers +from kiro_crew.dashboard.handlers import git_branches from kiro_crew.dashboard.handlers._shared import guarded_route from kiro_crew.dashboard.remote_chat_guard import remote_chat_refusal @@ -65,6 +66,12 @@ def register(app: web.Application) -> None: app.router.add_get("/api/project/git/status", _local_files(handlers.api_project_git_status)) app.router.add_get("/api/project/git/log", _local_files(handlers.api_project_git_log)) app.router.add_get("/api/project/git/repos", handlers.api_project_git_repos) + app.router.add_get( + "/api/project/git/branches", _local_files(git_branches.api_project_git_branches) + ) + app.router.add_post( + "/api/project/git/switch", _local_files(git_branches.api_project_git_switch) + ) app.router.add_get("/api/project/tree", handlers.api_project_tree) app.router.add_post("/api/upload", handlers.api_upload) app.router.add_post("/api/upload/file", handlers.api_upload_file) diff --git a/src/kiro_crew/dashboard/state_parts/chat_slot.py b/src/kiro_crew/dashboard/state_parts/chat_slot.py index 1df8948766e..78285e3034f 100644 --- a/src/kiro_crew/dashboard/state_parts/chat_slot.py +++ b/src/kiro_crew/dashboard/state_parts/chat_slot.py @@ -87,6 +87,8 @@ class _ChatSlot: "memory_store", "_memory_assignment_from_history", "project", + "_checkout_turn_projects", + "_checkout_turn_unplaced", "created_at", "_messages", "_lazy_window", @@ -396,6 +398,8 @@ class _ChatSlot: memory_store: str _memory_assignment_from_history: bool project: str + _checkout_turn_projects: tuple[str, ...] + _checkout_turn_unplaced: bool executor: str instance_id: str remote_slot: str diff --git a/src/kiro_crew/dashboard/state_parts/dashboard_state.py b/src/kiro_crew/dashboard/state_parts/dashboard_state.py index 709c48fe05b..4b763b1ba61 100644 --- a/src/kiro_crew/dashboard/state_parts/dashboard_state.py +++ b/src/kiro_crew/dashboard/state_parts/dashboard_state.py @@ -297,6 +297,15 @@ def __init__( # entry path is protected, including task/workflow continuations. self.kiro_prerequisite_service: Any = None self.subagents = subagents + if subagents is not None: + # Sub-agent runs wait out a branch switch on their repository the + # same way turns do at ``_run_chat``'s entry. + from kiro_crew.dashboard import repo_checkout_guard + + async def _checkout_gate(info: Any) -> None: + await repo_checkout_guard.wait_for_subagent_checkout(self, info) + + subagents.pre_run_gate = _checkout_gate self.channel_manager: Any = None # lazy-init in server.py # A gateway launch defers legacy channel-agent relaunch until memory # preparation settles. Standalone dashboard callers keep the immediate diff --git a/src/kiro_crew/dashboard/state_parts/slot_session.py b/src/kiro_crew/dashboard/state_parts/slot_session.py index 06aa46e81c5..01d26656584 100644 --- a/src/kiro_crew/dashboard/state_parts/slot_session.py +++ b/src/kiro_crew/dashboard/state_parts/slot_session.py @@ -220,6 +220,16 @@ def init_session_fields(self: _ChatSlot) -> None: # that admission boundary; this marker is not persisted in the transcript. self._memory_assignment_from_history = False self.project = "" + # Where the running turn works, for the branch switch's busy check + # (``repo_checkout_guard.slot_folders``). ``project`` does not track it: + # a project picked mid-turn, ``set_project`` and a member rebinding all + # write ``project`` while the turn's session keeps its folder. The turn + # records the project at entry, the project its session was allocated + # for and the folder that session reports; ``_checkout_turn_unplaced`` + # is True from the start of session allocation until that report. + # Reset at ``_run_chat``'s entry; read only while ``running``. + self._checkout_turn_projects = () + self._checkout_turn_unplaced = False # Relay-archive binding, read off disk. ``executor`` is "local" for every # ordinary slot; "remote" marks a chat an older build ran on the peer # ``instance_id`` (its slot ``remote_slot``). Such a slot is read-only: diff --git a/src/kiro_crew/knowledge/llm_pool.py b/src/kiro_crew/knowledge/llm_pool.py index 792d1a4f072..403063a1348 100644 --- a/src/kiro_crew/knowledge/llm_pool.py +++ b/src/kiro_crew/knowledge/llm_pool.py @@ -13,10 +13,11 @@ import os import shutil import time +import uuid from abc import ABC, abstractmethod from typing import Any, Optional -from kiro_crew import platform_compat +from kiro_crew import platform_compat, repo_checkout_guard from kiro_crew.agent_sdk.backends import ( ACP_BACKEND_KIRO, POLICY_ID_BY_BACKEND, @@ -771,6 +772,12 @@ def __init__(self, *, sandbox_mode: Optional[str] = None) -> None: # rather than take ``wrap_argv``'s own default. ``None`` -> resolve # lazily in ``_spawn`` (direct construction outside the pool / tests). self._sandbox_mode = sandbox_mode + # For the branch switch's busy check (see send_message): a fresh id per + # spawn, so a respawned CLI never answers its predecessor's prompt, and + # the folder the CLI inherits (it is spawned with no cwd of its own). + self._instance = "" + self._prompt_seq = 0 + self._spawn_cwd = "" async def start(self) -> None: self._claude_bin = shutil.which("claude") @@ -877,6 +884,8 @@ async def _spawn_process(self) -> None: # pipe, so it is the only reader this limit sizes. limit=STDOUT_LINE_LIMIT, ) + self._instance = uuid.uuid4().hex + self._spawn_cwd = os.getcwd() self._event_queue = asyncio.Queue() self._reader_task = asyncio.create_task(self._stdout_reader()) @@ -913,8 +922,25 @@ async def send_message(self, prompt: str, timeout: float = DEFAULT_TIMEOUT) -> s stdin = self._proc.stdin msg = json.dumps({"type": "user", "message": {"role": "user", "content": prompt}}) + # The branch switch's busy check counts this prompt from before its write + # until its result event is read (repo_checkout_guard), the way the ACP + # transports count theirs. This worker speaks no ACP and confirms no + # process-tree exit, so a reply that never completes keeps the folder + # the CLI inherited counted until the gateway restarts. + folder = str(getattr(self, "_spawn_cwd", "") or "") + await repo_checkout_guard.before_prompt(folder) + if self._proc is None or self._proc.stdin is not stdin: + raise RuntimeError("CCWorker process died during message") + self._prompt_seq = getattr(self, "_prompt_seq", 0) + 1 + prompt_id = self._prompt_seq + instance = str(getattr(self, "_instance", "") or "") + repo_checkout_guard.prompt_sent(instance, prompt_id, folder) + + written = False async def _deliver_and_collect() -> str: + nonlocal written + written = True stdin.write((msg + "\n").encode()) await stdin.drain() return await self._collect_response() @@ -936,7 +962,12 @@ async def _deliver_and_collect() -> str: settled = True # the error result is the result event raise finally: - if not settled: + if settled: + repo_checkout_guard.prompt_answered(instance, prompt_id) + else: + if not written: + # Never reached the pipe, so nothing runs it. + repo_checkout_guard.prompt_unsent(instance, prompt_id) await self.shutdown() async def _collect_response(self) -> str: diff --git a/src/kiro_crew/repo_checkout_guard.py b/src/kiro_crew/repo_checkout_guard.py new file mode 100644 index 00000000000..9302ab762b5 --- /dev/null +++ b/src/kiro_crew/repo_checkout_guard.py @@ -0,0 +1,398 @@ +"""Per-repository checkout reservations that order a branch switch against agent work. + +A branch switch rewrites the working tree under every session whose project lies +in the repository. The switch handler refuses while any of those sessions has a +turn running or sub-agents pending, but neither turn admission nor sub-agent +admission takes the switch's lock, so work admitted between that check and the +checkout would read files on one branch and write them back on the other. + +The two sides order themselves through this registry, on the event loop: + +* the switch calls :func:`reserve` synchronously, BEFORE it awaits its busy + check, and the reservation is released only when the checkout worker itself + has finished (:func:`release_when_done`), so a cancelled switch request cannot + drop it while git is still rewriting files; +* every turn calls :func:`wait_for_checkout` at its entry, before it reads any + project file, and every sub-agent run awaits + ``dashboard.repo_checkout_guard.wait_for_subagent_checkout`` just before it + executes; +* a turn also marks itself unplaced and awaits :func:`wait_for_any_checkout` + before it claims its session, since where that session runs is the + allocator's choice, then records the folder the session reports and waits + out any checkout of it. ``dashboard.repo_checkout_guard.slot_folders`` is what + the busy check reads; +* a workflow step opens its session outside chat slots and the sub-agent + manager, so it does the same through :func:`tracked_work`, and the busy check + reads :func:`tracked_work_folders`; +* underneath all of them, every prompt any agent process is sent -- whatever + drives it: a channel, a monitor wake, a cron or heartbeat turn, an app -- is + recorded at the ACP transport from before its write until the backend + answers it or the process tree is confirmed gone (:func:`prompt_sent`, see + "Prompts in flight" below), and :func:`tracked_work_folders` reads those too. + +Both kinds of work are published before their first step: a turn's task is on +its slot, and a sub-agent run is counted as its parent's pending work. So work +whose entry ran before the reservation is seen by the busy check, which refuses +the switch, and work whose entry runs after it waits until the checkout has +finished. The wait has no deadline: every git call the checkout makes is +bounded, so the worker always finishes, and work that started beside a live +checkout would reopen the race this registry closes. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import contextvars +import logging +import os +from collections.abc import AsyncIterator, Iterable +from typing import Any + +logger = logging.getLogger(__name__) + +# How often a waiter logs that it is still waiting on a checkout. +WAIT_LOG_SECS = 120.0 + +_reserved: dict[str, asyncio.Event] = {} + + +def reserve(root: str) -> bool: + """Reserve ``root`` (a resolved repository root) for one checkout. + + Returns False when another checkout already holds it. + """ + if root in _reserved: + return False + _reserved[root] = asyncio.Event() + return True + + +def release(root: str) -> None: + """Drop the reservation on ``root`` and wake everything waiting on it.""" + event = _reserved.pop(root, None) + if event is not None: + event.set() + + +def release_when_done(root: str, worker: asyncio.Future) -> None: + """Release ``root`` once ``worker`` finishes, however its awaiter ends.""" + + def _done(future: asyncio.Future) -> None: + if not future.cancelled(): + # Retrieved so an exception nobody awaited (the switch request was + # cancelled) is not reported as never retrieved. + future.exception() + release(root) + + worker.add_done_callback(_done) + + +def is_reserved(root: str) -> bool: + return root in _reserved + + +def is_any_reserved() -> bool: + """Whether any checkout is in flight.""" + return bool(_reserved) + + +def _covering_root(project: str, roots: Iterable[str]) -> str | None: + """The reserved root that contains ``project`` or that ``project`` contains.""" + resolved = os.path.realpath(project) + for root in roots: + try: + common = os.path.commonpath([resolved, root]) + except ValueError: # different drives on Windows + continue + if common in (resolved, root): + return root + return None + + +async def wait_for_checkout(project: str | None, *, waiter: str = "") -> None: + """Wait while a checkout holds the repository that ``project`` lies in. + + ``waiter`` names the work that is waiting (a session key or run id) for the + periodic log line, so a checkout that never finishes can be traced. + """ + if not project or not _reserved: + return + while _reserved: + snapshot = tuple(_reserved) + root = await asyncio.to_thread(_covering_root, project, snapshot) + if root is None: + # The match ran off the loop, so a switch may have reserved another + # root meanwhile. Return only when nothing new was reserved since the + # snapshot: the caller then records its work with no further await. + if all(reserved in snapshot for reserved in _reserved): + return + continue + event = _reserved.get(root) + if event is None: + continue + try: + await asyncio.wait_for(event.wait(), WAIT_LOG_SECS) + except asyncio.TimeoutError: + logger.warning( + "%s still waiting on a checkout of %s before starting work", + waiter or "work", + root, + ) + + +async def wait_for_any_checkout(*, waiter: str = "") -> None: + """Wait until no checkout is in flight anywhere. + + For work whose folder cannot be known yet, so no single repository's + reservation can be matched against it. + """ + while _reserved: + root, event = next(iter(_reserved.items())) + try: + await asyncio.wait_for(event.wait(), WAIT_LOG_SECS) + except asyncio.TimeoutError: + logger.warning("%s still waiting on a checkout of %s", waiter or "work", root) + + +class TrackedWork: + """Session work that runs outside chat slots and the sub-agent manager. + + A workflow step opens its own session through the session manager, so + neither the slot scan nor the sub-agent registry sees it. It registers + here instead (:func:`tracked_work`): unplaced while its session is being + acquired, since where the session lands is the allocator's choice, then + placed on the folder that session reports (:meth:`place`). A step whose + session reports no folder stays unplaced for its whole run. A turn the step + abandons -- cancelled, failed, cut off -- stays counted after the step ends + through its prompt's own record (:func:`prompt_sent`), until the backend + answers it or its process tree is confirmed gone. + """ + + __slots__ = ("folders", "placed", "waiter") + + def __init__(self, waiter: str) -> None: + self.folders: tuple[str, ...] = () + self.placed = False + self.waiter = waiter + + async def place(self, provider: Any, *also: object) -> None: + """Record where the work runs and wait out a checkout of any of it. + + ``provider`` is the session the work drives: the folder it reports is + where its native turn runs. ``also`` names any other folder the work + reads, such as the project its prompt is built against, which can + differ from a reused session's folder. Everything is recorded with no + await before it, so a busy check that runs after this call sees it and + one that ran before it saw the work unplaced. Only the provider's own + report places the work. + """ + session_folder = str(getattr(provider, "cwd", "") or "") + folders = [f for f in (session_folder, *(str(x or "") for x in also)) if f] + self.folders = (*self.folders, *folders) + if session_folder: + self.placed = True + for folder in folders: + await wait_for_checkout(folder, waiter=self.waiter) + + +_tracked: dict[int, TrackedWork] = {} + +#: The tracked work the current task runs inside, so a prompt it sends is tied +#: to it (:func:`prompt_sent`). Tasks the work starts inherit it. +_current_work: contextvars.ContextVar[TrackedWork | None] = contextvars.ContextVar( + "repo_checkout_tracked_work", default=None +) + + +@contextlib.asynccontextmanager +async def tracked_work(*, waiter: str = "") -> AsyncIterator[TrackedWork]: + """Register session work for the busy check for the length of the block. + + Registered unplaced before the first await, then waits out every checkout + in flight, so a switch either sees the work (and refuses) or finishes + before the work acquires its session. A prompt sent inside the block keeps + every folder the work recorded counted for as long as that prompt is, so a + turn the work abandons stays busy in the project its prompt was built + against too, not only in its session's folder. + """ + work = TrackedWork(waiter or "work") + _tracked[id(work)] = work + token = _current_work.set(work) + try: + await wait_for_any_checkout(waiter=work.waiter) + yield work + finally: + _tracked.pop(id(work), None) + with contextlib.suppress(ValueError): # exited in another context + _current_work.reset(token) + + +def tracked_work_folders() -> tuple[list[str], bool]: + """Folders tracked work runs in, and whether every piece of it is placed. + + Covers both kinds of record: work registered through :func:`tracked_work` + and every prompt in flight on any agent process (:func:`prompt_sent`), + together with every folder of the work that sent it. A prompt whose + session reports no folder counts as unplaced. + """ + works = list(_tracked.values()) + prompts = list(_prompts.values()) + folders = [folder for work in works for folder in work.folders if folder] + folders += [folder for work in list(_prompt_works.values()) for folder in work.folders] + folders += [folder for folder in prompts if folder] + return folders, all(work.placed for work in works) and all(prompts) + + +def folders_overlap(folders: Iterable[str], root: str) -> bool: + """Whether any of ``folders`` lies in ``root`` or contains it.""" + return any(_covering_root(folder, (root,)) is not None for folder in folders) + + +# ── Prompts in flight: the seam every agent turn passes ── +# +# Every harness sends a turn as an ACP request on one of two transports: +# ``AcpClient._send_request`` (the adapter harnesses: claude, codex, goose, +# opencode and the rest) and ``AcpRuntime``'s three request writers +# (``send_request``, ``send_request_for_answer``, ``_send_and_await``) on the +# shared kiro / KAS / codex runtime. Each calls :func:`before_prompt` before it +# reserves the request id and :func:`prompt_sent` synchronously once it has it, +# before the frame is written; their frame readers call :func:`prompt_answered` +# on every response, whoever reads it; and their teardowns call +# :func:`prompt_process_gone` once the runtime has confirmed the whole process +# tree exited. So whatever drives a turn -- a dashboard chat, a channel, a +# monitor wake, a cron or heartbeat turn, a sub-agent, a workflow step, an app +# -- the busy check sees it from before the send until the backend itself +# answers the prompt or its process is confirmed gone. A cancel, a timeout, a +# synthetic end of turn the session layer makes up, or a caller that stops +# reading leaves the record: none of them stops the backend's tools. The one +# agent worker that speaks no ACP, the knowledge pool's legacy ``CCWorker``, +# records its own prompts the same way. + +#: ``(process instance, request id) -> folder`` for each prompt not yet answered. +#: The process instance is a fresh random id per spawn, so a request id a +#: respawned process reuses never answers its predecessor's prompt. +_prompts: dict[tuple[str, int], str] = {} + +#: The tracked work each prompt in :data:`_prompts` was sent inside, so every +#: folder that work recorded stays counted until the prompt's own record goes. +_prompt_works: dict[tuple[str, int], TrackedWork] = {} + +#: The keys in :data:`_prompts` that are steers rather than turns. +_steers: set[tuple[str, int]] = set() + +#: The requests that start agent work: a turn, and a kiro slash command run +#: through its command extension. +TURN_METHODS = frozenset({"session/prompt", "_kiro.dev/commands/execute"}) + +#: The requests that carry a user's mid-turn message into a running turn. An +#: answer to one is not the end of a turn: the turn it joined is counted on its +#: own prompt. But an adapter that finds no turn running can start one of its +#: own (codex answers ``startedNewTurn``), and nothing then reports when that +#: turn ends, so such a steer stays counted until its process tree is gone. +STEER_METHODS = frozenset({"_session/steer", "_session/steering", "_goose/unstable/session/steer"}) + +PROMPT_METHODS = TURN_METHODS | STEER_METHODS + +#: Request families that rewrite working-tree files themselves: KAS restores a +#: checkpoint's files (``_kiro/checkpoint/revertMultiple``). Matched by prefix so +#: a checkpoint verb added later is counted without a list to update. +RESTORE_METHOD_PREFIXES = ("_kiro/checkpoint/",) + +#: The steering outcome by which an adapter says it started a turn of its own. +STARTED_NEW_TURN = "startedNewTurn" + +#: Keys of steers answered :data:`STARTED_NEW_TURN`: latched until the process +#: tree is confirmed gone, so no later frame with the same id releases them. +_adapter_turns: set[tuple[str, int]] = set() + + +def is_tracked(method: str) -> bool: + """Whether a request starts agent work or rewrites files, so it is counted.""" + return method in PROMPT_METHODS or method.startswith(RESTORE_METHOD_PREFIXES) + + +async def before_prompt(folder: str, *, waiter: str = "") -> None: + """Wait out a checkout of ``folder`` (of every repository when it is unknown). + + Returns without suspending when no checkout is in flight, so a prompt sent + with no switch in progress gains no wait and no reordering. When it does + wait, it returns only once a check found nothing reserved that covers the + folder with no await after it (:func:`wait_for_checkout`), so the caller's + synchronous :func:`prompt_sent` lands before any later switch can reserve. + """ + if not _reserved: + return + if folder: + await wait_for_checkout(folder, waiter=waiter or "prompt") + else: + await wait_for_any_checkout(waiter=waiter or "prompt") + + +def prompt_sent(instance: str, request_id: int, folder: str, *, method: str = "") -> None: + """Record a prompt about to be written; ``folder`` is where its session runs.""" + key = (str(instance), request_id) + _prompts[key] = str(folder or "") + work = _current_work.get() + if work is not None: + _prompt_works[key] = work + if method in STEER_METHODS: + _steers.add(key) + + +def _issued_id(request_id: Any) -> int | None: + """``request_id`` when it can be an id this host issued: a plain int, exactly.""" + return request_id if type(request_id) is int else None + + +def prompt_answered(instance: str, request_id: Any, result: Any = None) -> None: + """The backend answered request ``request_id`` (any id: most are not prompts). + + Called only with a response frame (no method) and its own ``id`` as the wire + carried it, unconverted: only an int equal to the issued id answers a prompt, + so a ``1.9``, a ``"1"`` or a ``true`` never ends prompt 1. A steer answered + ``startedNewTurn`` stays counted (:data:`STEER_METHODS`). + """ + if not _prompts: + return + rid = _issued_id(request_id) + if rid is None: + return + key = (str(instance), rid) + if key not in _prompts or key in _adapter_turns: + return + if key in _steers: + _steers.discard(key) + outcome = result.get("outcome") if isinstance(result, dict) else None + if outcome == STARTED_NEW_TURN: + _adapter_turns.add(key) + return + _prompts.pop(key, None) + _prompt_works.pop(key, None) + + +def prompt_unsent(instance: str, request_id: int) -> None: + """Drop a prompt whose frame was never written, so no backend can answer it. + + A writer records a prompt before the write (:func:`prompt_sent`) so no + switch slips in between; when the write then ends before a byte of the frame + reached the pipe (cancelled while waiting for the shared write lock, refused + because the runtime died, or the lock wait stalled), nothing runs and nothing + will ever answer it. Once the frame reached the pipe it stays counted. + """ + key = (str(instance), request_id) + _prompts.pop(key, None) + _prompt_works.pop(key, None) + _steers.discard(key) + _adapter_turns.discard(key) + + +def prompt_process_gone(instance: str) -> None: + """Drop every prompt of a process whose whole tree is confirmed exited.""" + if not instance or not _prompts: + return + for key in [key for key in _prompts if key[0] == instance]: + _prompts.pop(key, None) + _prompt_works.pop(key, None) + _steers.discard(key) + _adapter_turns.discard(key) diff --git a/src/kiro_crew/security_posture_sinks_delivery.py b/src/kiro_crew/security_posture_sinks_delivery.py index 4e37f29a7a1..c68d575a994 100644 --- a/src/kiro_crew/security_posture_sinks_delivery.py +++ b/src/kiro_crew/security_posture_sinks_delivery.py @@ -525,6 +525,13 @@ "status and log (api_project_git_status, api_project_git_log) return are " "redacted; file paths go through redact_path_segments.", ), + ( + "Git panel branch switcher", + "dashboard/handlers/git_branches.py", + "The branch names, commit subjects and authors the branch " + "switcher (api_project_git_branches, api_project_git_switch) returns are " + "redacted; a name the redactor alters is listed as not switchable.", + ), ( "Project file tree", "dashboard/file_api/project_tree.py", diff --git a/src/kiro_crew/subagent_manager/run_lifecycle.py b/src/kiro_crew/subagent_manager/run_lifecycle.py index 002caa61442..95d7fbb345c 100644 --- a/src/kiro_crew/subagent_manager/run_lifecycle.py +++ b/src/kiro_crew/subagent_manager/run_lifecycle.py @@ -23,7 +23,7 @@ class binds it under the same name in ``subagent_manager/run.py``. The ``*_impl` ``_release_slot``, ``_retain_process_handles``, ``_run_inner``, ``_schedule_cancel_recovery``, ``_sessions``, ``_sessions_under``, ``_shutting_down``, ``_sigkill_sessions``, ``_spawn_terminal_report``, ``_taskq_dependency_coordinator``, -``_teardown_run_session``, ``_write_tombstone``, ``arm_report_in_flight``. +``_teardown_run_session``, ``_write_tombstone``, ``arm_report_in_flight``, ``pre_run_gate``. """ from __future__ import annotations @@ -327,6 +327,19 @@ async def _run_impl(self: RunEventCoordinator, info: SubagentInfo) -> None: # config reload between the two attempts cannot move it. if info.timeout_secs == 0: info.timeout_secs = self._manager._default_timeout + # Inside the ``try`` so a Stop or shutdown during the wait ends the + # run through the same cancellation arms as one during execution. + pre_run_gate = getattr(self._manager, "pre_run_gate", None) + # A continuation still carries the folder its previous execution's + # session started in. Cleared before the gate (``_run_inner_impl`` + # clears it again on entry) so the checkout guard reads this run as + # not placed yet until its new session records where it started. + setattr(info, "_session_cwd", "") + # Imported here: an ``_impl`` body runs with the facade module's globals. + import inspect + + if inspect.iscoroutinefunction(pre_run_gate): + await pre_run_gate(info) await asyncio.wait_for( self._manager._run_inner(info, session_key), timeout=info.timeout_secs ) diff --git a/src/kiro_crew/subagent_parts/manager.py b/src/kiro_crew/subagent_parts/manager.py index 54dcb0098a1..701e6ef475f 100644 --- a/src/kiro_crew/subagent_parts/manager.py +++ b/src/kiro_crew/subagent_parts/manager.py @@ -227,6 +227,11 @@ def __init__( self._on_spawn_approval = on_spawn_approval self._is_yolo = is_yolo self._on_event = on_event + # Awaited by every run just before it executes, after the run is already + # visible as pending work for its parent. The dashboard installs one that + # waits out a branch switch on the run's repository + # (``kiro_crew.dashboard.repo_checkout_guard.wait_for_subagent_checkout``). + self.pre_run_gate: Callable[[SubagentInfo], Awaitable[None]] | None = None # Orphan-notification delivery (gateway-wired). ``on_orphan_notify`` # injects a message into the parent dashboard slot (returns True on # success); ``on_orphan_dm`` is the owner-DM / notification fallback. diff --git a/src/kiro_crew/workflows/agent_exec.py b/src/kiro_crew/workflows/agent_exec.py index af73524c9c3..30af073036f 100644 --- a/src/kiro_crew/workflows/agent_exec.py +++ b/src/kiro_crew/workflows/agent_exec.py @@ -154,8 +154,16 @@ def build_agent_fn( # Per-run, 0-based ephemeral session index (not a module-global, so each run # restarts at :0 as the ``wf:{run_id}:{call_index}`` contract documents). counter = itertools.count() + from kiro_crew.repo_checkout_guard import tracked_work async def agent_fn(prompt: str, opts: dict) -> Any: + # A workflow step opens its own session, outside chat slots and the + # sub-agent manager, so it registers with the branch switch's busy check + # itself for the whole step (see repo_checkout_guard.tracked_work). + async with tracked_work(waiter=f"workflow {run_id}") as work: + return await _tracked_agent_fn(prompt, opts, work) + + async def _tracked_agent_fn(prompt: str, opts: dict, work: Any) -> Any: # Spawn gates FIRST: a refused step must allocate no session, touch no # memory scope and consume no session index. step_cwd = await vet_step_spawn( @@ -186,6 +194,10 @@ async def agent_fn(prompt: str, opts: dict) -> Any: # setup is not charged to the turn. _turn_t0 = time.monotonic() try: + # Where the step's session actually runs, and the project its prompt + # is built against, before any of its work. A turn the step abandons + # stays counted on its prompt. + await work.place(provider, step_cwd or cwd) from kiro_crew.messaging.identity import publish_turn_identity await publish_turn_identity(sessions, key) diff --git a/src/kiro_crew/workflows/agent_pool.py b/src/kiro_crew/workflows/agent_pool.py index 8cbd78949d4..f997670d484 100644 --- a/src/kiro_crew/workflows/agent_pool.py +++ b/src/kiro_crew/workflows/agent_pool.py @@ -131,6 +131,15 @@ def __init__( async def start(self) -> None: """Cold-start THIS worker's session once (the only cold start it pays).""" + # The pool warms workers ahead of any step, so the cold start registers + # with the branch switch's busy check on its own, as a step does. + from kiro_crew.repo_checkout_guard import tracked_work + + async with tracked_work(waiter=f"workflow worker {self._key}") as work: + await self._start_tracked() + await work.place(self._provider, self._cwd) + + async def _start_tracked(self) -> None: if self._memory_scope is not None: await self._memory_scope.prepare(self._context_builder, self._key) provider, self._is_new, self._resumed = await self._sessions.get_or_create( @@ -143,6 +152,19 @@ async def start(self) -> None: self._provider = provider async def send_message(self, prompt: str, timeout: float = 1800.0) -> str: + # A workflow step's session runs outside chat slots and the sub-agent + # manager, so the step registers with the branch switch's busy check + # itself: unplaced until its session exists, then on the folder that + # session reports (see repo_checkout_guard.tracked_work). + from kiro_crew.repo_checkout_guard import tracked_work + + async with tracked_work(waiter=f"workflow worker {self._key}") as work: + if self._provider is None: + await self.start() + await work.place(self._provider, self._cwd) + return await self._send_placed(prompt, timeout) + + async def _send_placed(self, prompt: str, timeout: float) -> str: if self._provider is None: await self.start() # Publish this turn's session identity so managed MCP tools resolve @@ -181,7 +203,20 @@ async def reset(self) -> None: claude ACP both do), which skips the expensive spawn+initialize. If it is unavailable or fails, fall back to a hard ``SessionManager.reset`` so a reused worker can NEVER carry prior-task context into the next task - (correctness over speed on the fallback path).""" + (correctness over speed on the fallback path). + + Registered with the branch switch's busy check for the whole reset, as + ``start`` and ``send_message`` are: even the cheap path opens a new + conversation, which reads the project's agent specs. + """ + from kiro_crew.repo_checkout_guard import tracked_work + + async with tracked_work(waiter=f"workflow worker {self._key}") as work: + await self._reset_tracked() + if self._provider is not None: + await work.place(self._provider, self._cwd) + + async def _reset_tracked(self) -> None: if self._memory_scope is not None: await self._memory_scope.prepare(self._context_builder, self._key) # Lifecycle only; EssentialDelivery owns successful delivery evidence. @@ -359,6 +394,14 @@ def _pool_for( _unpooled = itertools.count() async def _run_unpooled(prompt: str, opts: dict) -> Any: + # Registered with the branch switch's busy check for the whole step, + # as the pooled worker's turn is (see _WorkflowSessionWorker.send_message). + from kiro_crew.repo_checkout_guard import tracked_work + + async with tracked_work(waiter=f"workflow {run_id}") as work: + return await _run_unpooled_tracked(prompt, opts, work) + + async def _run_unpooled_tracked(prompt: str, opts: dict, work: Any) -> Any: named = opts.get("session") key = named if named is not None else f"wf-unpooled:{run_id}:{next(_unpooled)}" if memory_scope is not None: @@ -373,6 +416,9 @@ async def _run_unpooled(prompt: str, opts: dict) -> Any: extra_env=extra_env, ) try: + # As the pooled worker's turn: placed on its session's folder before + # any of its work. + await work.place(provider, opts.get("cwd") or cwd) # Same identity publication as the pooled worker (see # _WorkflowSessionWorker.send_message): a named ``session=`` chain # and the identity-cap overflow session both run on their own diff --git a/test/conftest.py b/test/conftest.py index bc2304dc87a..0e6b7e26489 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -295,6 +295,7 @@ def bundled_python_with_user_site(monkeypatch: pytest.MonkeyPatch) -> None: _release_source_corpus_after_module, _release_stt_engine, _reset_channel_turn_ceiling, + _reset_checkout_guard_tracking, _reset_cjk_segmenter, _reset_create_rate_limit_buckets, _reset_crewmate_worker_slots, diff --git a/test/conftest_resets.py b/test/conftest_resets.py index 7b11b3741da..9ee7436de3f 100644 --- a/test/conftest_resets.py +++ b/test/conftest_resets.py @@ -638,6 +638,19 @@ def _reset_session_switch_locks(_floor_monkeypatch): ) +@pytest.fixture(autouse=True) +def _reset_checkout_guard_tracking(_floor_monkeypatch): + """Each test gets a fresh checkout-guard registry, so one test's tracked work + or unanswered prompt never reads as running work in the next.""" + from kiro_crew import repo_checkout_guard + + _floor_monkeypatch.setattr(repo_checkout_guard, "_tracked", {}) + _floor_monkeypatch.setattr(repo_checkout_guard, "_prompts", {}) + _floor_monkeypatch.setattr(repo_checkout_guard, "_prompt_works", {}) + _floor_monkeypatch.setattr(repo_checkout_guard, "_steers", set()) + _floor_monkeypatch.setattr(repo_checkout_guard, "_adapter_turns", set()) + + @pytest.fixture(scope="session", autouse=True) def _shut_down_shared_pools_in_session(): """Reap the lazily created shared executors before the session ends. diff --git a/test/test_acp_refactor_facade.py b/test/test_acp_refactor_facade.py index 8546ba22c88..4bff3348227 100644 --- a/test/test_acp_refactor_facade.py +++ b/test/test_acp_refactor_facade.py @@ -1814,7 +1814,8 @@ def test_the_launch_capture_patches_no_moved_name() -> None: #: facade's write also reaches in the owner modules that code moved to. Every entry is #: patched for code of the facade's own family -- the client's stderr drain and session #: init (``logger``), ``AcpClient.send_command`` (``redact_exfiltration_urls``), the -#: runtime's response bound and descendant snapshot -- or for the launch the driver +#: runtime's response bound and descendant snapshot, the failed-start escapee kill +#: (``_kill_failed_start_escapees`` stayed in the runtime) -- or for the launch the driver #: starts: each driver hands the launch tail its OWN sandbox wrap, scrub and scratch #: module (``_launch_tools``), so those patches reach the launch they drive, while a #: host's routing read-back wraps and scrubs with its adapter module's binding, which @@ -1830,6 +1831,7 @@ def test_the_launch_capture_patches_no_moved_name() -> None: ("kiro_crew.acp.client", "wrap_argv_async"), ("kiro_crew.acp.runtime", "_RESPONSE_WRITE_BOUND_SECS"), ("kiro_crew.acp.runtime", "_get_child_pids"), + ("kiro_crew.acp.runtime", "_kill_escaped_children"), ("kiro_crew.acp.runtime", "agent_scratch"), ("kiro_crew.acp.runtime", "launch"), ("kiro_crew.acp.runtime", "scrub_agent_subprocess_env"), diff --git a/test/test_acp_runtime_death.py b/test/test_acp_runtime_death.py index 3ca3ce27d60..724fb1826a5 100644 --- a/test/test_acp_runtime_death.py +++ b/test/test_acp_runtime_death.py @@ -1016,3 +1016,92 @@ async def close(): rt._remote_workspace = {"server": {"name": "workspace"}, "workdir": "/w", "close": close} await rt.kill(expected=True, reason="reset") assert closed.is_set() + + +class TestRepeatKillAfterSurvivors: + """A teardown that left descendants for the orphan sweep is re-read by a later kill.""" + + @staticmethod + def _after_teardown(monkeypatch, alive_sets): + """A runtime whose kill already ran: root dead, pid 777 left for the sweep.""" + import kiro_crew.acp.runtime as rt_mod + + rt, _, _ = _make_runtime() + rt._process = None + rt._teardown_root_dead = True + rt._teardown_survivors = {777: object()} + answers = iter(alive_sets) + monkeypatch.setattr(rt_mod, "_prune_dead_descendants", lambda saved: next(answers)) + return rt + + @pytest.mark.asyncio + async def test_the_tree_stays_unconfirmed_while_a_survivor_runs(self, monkeypatch): + rt = self._after_teardown(monkeypatch, [[777]]) + await rt.kill() + assert rt.process_tree_confirmed_dead is False + assert list(rt._teardown_survivors) == [777] + + @pytest.mark.asyncio + async def test_the_tree_is_confirmed_once_the_survivor_is_gone(self, monkeypatch): + rt = self._after_teardown(monkeypatch, [[777], []]) + await rt.kill() + assert rt.process_tree_confirmed_dead is False + await rt.kill() + assert rt.process_tree_confirmed_dead is True + assert rt._teardown_survivors == {} + + @pytest.mark.asyncio + async def test_a_root_not_proved_dead_is_never_confirmed_by_a_repeat_kill(self, monkeypatch): + rt = self._after_teardown(monkeypatch, []) + rt._teardown_root_dead = False + rt._teardown_survivors = {} + await rt.kill() + assert rt.process_tree_confirmed_dead is False + + @pytest.mark.asyncio + async def test_a_kill_records_its_survivors_for_the_next_kill(self, monkeypatch): + import kiro_crew.acp.runtime as rt_mod + + if rt_mod.platform_compat.IS_WINDOWS: + pytest.skip("the Windows owned drain confirms the whole tree itself") + rt, _, proc = _make_runtime() + proc.wait = AsyncMock(return_value=0) + proc.returncode = 0 + rt._child_pids = {777: object()} + monkeypatch.setattr(rt_mod, "_untrack_pid", lambda p: None) + monkeypatch.setattr(rt_mod, "_untrack_session_pid", lambda p: None) + monkeypatch.setattr(rt_mod.platform_compat, "pid_exists", lambda pid: False) + monkeypatch.setattr(rt_mod.platform_compat, "kill_process_tree", lambda *a: None) + monkeypatch.setattr(rt_mod, "_prune_dead_descendants", lambda saved: list(saved)) + await rt.kill() + assert rt.process_tree_confirmed_dead is False + assert rt._teardown_root_dead is True + assert list(rt._teardown_survivors) == [777] + + @pytest.mark.asyncio + async def test_a_failed_start_survivor_keeps_a_repeat_kill_unconfirmed(self, monkeypatch): + import kiro_crew.acp.runtime as rt_mod + + rt = self._after_teardown(monkeypatch, [[888], [888]]) + rt._teardown_survivors = {} + rt._FAILED_START_REAP_WAIT = 0.0 + monkeypatch.setattr(rt_mod, "_kill_escaped_children", lambda escapees: None) + await rt._kill_failed_start_escapees({888: object()}) + await rt.kill() + assert rt.process_tree_confirmed_dead is False + assert list(rt._teardown_survivors) == [888] + + @pytest.mark.asyncio + async def test_an_inconclusive_failed_start_reap_is_never_confirmed(self, monkeypatch): + import kiro_crew.acp.runtime as rt_mod + + rt = self._after_teardown(monkeypatch, []) + rt._teardown_survivors = {} + + def _boom(escapees): + raise OSError("reap failed") + + monkeypatch.setattr(rt_mod, "_kill_escaped_children", _boom) + await rt._kill_failed_start_escapees({888: object()}) + await rt.kill() + assert rt.process_tree_confirmed_dead is False diff --git a/test/test_json_object_body_guard.py b/test/test_json_object_body_guard.py index 2175b5cf6df..ca0a2f2d656 100644 --- a/test/test_json_object_body_guard.py +++ b/test/test_json_object_body_guard.py @@ -440,6 +440,9 @@ async def json(self): "handlers/workflows.py::api_workflow_run_intent": ("None", _UNBOUNDED_USER_CONTENT), "handlers/workflows.py::api_workflow_run_promote": ("", _BOUNDED_CONTROL_FIELDS), "handlers/workflows.py::api_workflow_run_rerun": ("None", _UNBOUNDED_USER_CONTENT), + # A branch switch names a project path, a branch, and an optional remote ref + # to track: control fields only, so the shared default ceiling applies. + "handlers/git_branches.py::api_project_git_switch": ("", _BOUNDED_CONTROL_FIELDS), # handlers/files.py and its file_api owners: bodies name paths and routing # fields -- the file bytes travel in api_file_write's body, which is the one # uncapped site. diff --git a/test/test_project_git_branches.py b/test/test_project_git_branches.py new file mode 100644 index 00000000000..6186c074241 --- /dev/null +++ b/test/test_project_git_branches.py @@ -0,0 +1,595 @@ +"""Tests for the Git panel's branch switcher routes. + +``GET /api/project/git/branches`` and ``POST /api/project/git/switch`` run real +git (through the worktree module's sandboxed ``_run_git``) against a throwaway +repository, so these tests skip where the OS sandbox cannot run git, exactly as +``test_worktree_create.py`` does. +""" + +from __future__ import annotations + +import functools +import os +import shutil +import subprocess +import tempfile +from unittest.mock import MagicMock + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from git_maintenance_helpers import git_maintenance_off_entries + +from kiro_crew import sandbox as sandbox_mod +from kiro_crew.dashboard.handlers import git_branches as gb +from kiro_crew.dashboard.handlers.worktree import SandboxUnavailable, _run_git + +_GIT_ENV = { + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, +} + +_REDACTABLE = "feat/ghp_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8" + + +def _git(*args: str, cwd, **env: str) -> str: + full_env = {**os.environ, **_GIT_ENV, **env} + # Pointing GIT_CONFIG_GLOBAL away drops the suite's global file, so the + # maintenance entries go on the env this helper built. + full_env.update(git_maintenance_off_entries(full_env)) + return subprocess.run( + ["git", *args], + cwd=str(cwd), + check=True, + capture_output=True, + encoding="utf-8", + env=full_env, + ).stdout.strip() + + +@functools.lru_cache(maxsize=1) +def _sandbox_exec_reason() -> str: + sandbox_mod.reset_backend() + with tempfile.TemporaryDirectory() as tmp: + try: + proc = _run_git(["--version"], tmp) + except SandboxUnavailable as exc: + return str(exc) or "sandbox unavailable" + except OSError as exc: # pragma: no cover - no git binary at all + return f"git unavailable: {exc}" + return "" if proc.returncode == 0 else (proc.stderr or "git failed").strip() + + +@pytest.fixture(scope="session", autouse=True) +def _sandbox_backend_session_boundary(): + yield + sandbox_mod.reset_backend() + + +@pytest.fixture(scope="session") +def _repo_template(tmp_path_factory): + """``main`` (current) plus ``older``, ``feature``, and three remote refs.""" + template = tmp_path_factory.mktemp("branches-seed") / "proj" + template.mkdir() + _git("init", "-q", "-b", "main", cwd=template) + _git("config", "gc.auto", "0", cwd=template) + _git("config", "maintenance.auto", "false", cwd=template) + (template / "README.md").write_text("hi\n") + _git("add", "README.md", cwd=template) + _git( + "commit", + "-q", + "-m", + "init", + cwd=template, + GIT_AUTHOR_DATE="2020-01-01T00:00:00Z", + GIT_COMMITTER_DATE="2020-01-01T00:00:00Z", + ) + _git("branch", "older", cwd=template) + _git("switch", "-q", "-c", "feature", cwd=template) + (template / "README.md").write_text("feature\n") + _git("commit", "-q", "-am", "feature work", cwd=template) + _git("switch", "-q", "main", cwd=template) + head = _git("rev-parse", "HEAD", cwd=template) + feature = _git("rev-parse", "feature", cwd=template) + # A configured remote (never contacted) so tracking can be set up against it. + _git("remote", "add", "origin", "https://example.invalid/repo.git", cwd=template) + # Remote-tracking refs without a network: one with no local twin, one whose + # short name matches a local branch, and the remote's HEAD symref. + _git("update-ref", "refs/remotes/origin/remote-only", feature, cwd=template) + _git("update-ref", "refs/remotes/origin/feature", feature, cwd=template) + _git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/feature", cwd=template) + _git("update-ref", f"refs/heads/{_REDACTABLE}", head, cwd=template) + return template + + +@pytest.fixture +def repo(tmp_path, request): + reason = _sandbox_exec_reason() + if reason: + pytest.skip(f"sandboxed git cannot run on this host: {reason[:120]}") + root = tmp_path / "proj" + shutil.copytree(request.getfixturevalue("_repo_template"), root) + return root + + +def _make_app(*projects, user: str = "owner") -> web.Application: + @web.middleware + async def claims(request: web.Request, handler): + request["app"] = "" + request["user"] = user + return await handler(request) + + app = web.Application(middlewares=[claims]) + state = MagicMock() + state.owner_id = "owner" + state.subagents = None + state._slots = { + f"chat-{i}": MagicMock(project=str(p), running=False) for i, p in enumerate(projects) + } + app["state"] = state + app.router.add_get("/api/project/git/branches", gb.api_project_git_branches) + app.router.add_post("/api/project/git/switch", gb.api_project_git_switch) + return app + + +async def _client(*projects, user: str = "owner") -> TestClient: + client = TestClient(TestServer(_make_app(*projects, user=user))) + await client.start_server() + return client + + +async def _switch(client: TestClient, path, **body): + resp = await client.post("/api/project/git/switch", json={"path": str(path), **body}) + return resp.status, await resp.json() + + +def _current(root) -> str: + return _git("branch", "--show-current", cwd=root) + + +class TestListBranches: + @pytest.mark.asyncio + async def test_lists_local_newest_first_and_marks_current(self, repo): + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 200 + assert body["repo"] is True + assert body["current"] == "main" + names = [r["name"] for r in body["local"]] + # `feature` carries the only newer commit, so it sorts first. + assert names[0] == "feature" + assert set(names) >= {"main", "older", "feature"} + current = [r for r in body["local"] if r["current"]] + assert [r["name"] for r in current] == ["main"] + feature = next(r for r in body["local"] if r["name"] == "feature") + assert feature["subject"] == "feature work" + assert feature["author"] == "Test" + assert feature["switchable"] is True + assert set(feature) == {"name", "date", "author", "subject", "current", "switchable"} + assert "switchBlocked" not in body + + @pytest.mark.asyncio + async def test_remote_rows_skip_head_symref_and_local_twins(self, repo): + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert [r["name"] for r in body["remote"]] == ["origin/remote-only"] + + @pytest.mark.asyncio + async def test_redacted_name_is_listed_but_not_switchable(self, repo): + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + redacted = [r for r in body["local"] if "REDACTED" in r["name"]] + assert len(redacted) == 1 + assert redacted[0]["switchable"] is False + assert _REDACTABLE not in str(body) + + @pytest.mark.asyncio + async def test_filter_driver_blocks_switching_but_still_lists(self, repo): + _git("config", "filter.lfs.smudge", "git-lfs smudge -- %f", cwd=repo) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + status, switched = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert resp.status == 200 + assert body["switchBlocked"] == "filter" + assert body["local"] + assert status == 409 + assert switched["code"] == "git_switch_filter_refused" + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_unknown_project_is_refused(self, repo, tmp_path): + client = await _client(tmp_path / "elsewhere") + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 403 + assert body["code"] == "unknown_project_dir" + + @pytest.mark.asyncio + async def test_non_repository_answers_repo_false(self, repo, tmp_path): + # `repo` only applies the sandbox skip; the project below is no repository. + plain = tmp_path / "plain" + plain.mkdir() + client = await _client(plain) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(plain)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 200 + assert body == {"repo": False, "local": [], "remote": []} + + @pytest.mark.asyncio + async def test_failed_probe_that_is_not_an_absence_is_an_outage(self, repo, monkeypatch): + # Dubious ownership (or a corrupt .git) is not "no repository": the + # picker must not say so under a chip that shows the repo's branch. + def dubious(args, cwd, **kwargs): + return subprocess.CompletedProcess( + args, 128, "", "fatal: detected dubious ownership in repository at '/x'\n" + ) + + monkeypatch.setattr(gb, "_run_git", dubious) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 503 + assert body["code"] == "git_branches_unavailable" + + @pytest.mark.asyncio + async def test_long_subject_and_author_are_bounded(self, repo): + _git("switch", "-q", "-c", "wordy", cwd=repo) + (repo / "w.txt").write_text("w\n") + _git("add", "w.txt", cwd=repo) + _git("commit", "-q", "-m", "s" * 5000, cwd=repo, GIT_AUTHOR_NAME="a" * 5000) + _git("switch", "-q", "main", cwd=repo) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + wordy = next(r for r in body["local"] if r["name"] == "wordy") + assert len(wordy["subject"]) == gb.MAX_ROW_TEXT + assert len(wordy["author"]) == gb.MAX_ROW_TEXT + assert wordy["subject"].endswith("\u2026") + + @pytest.mark.asyncio + async def test_listing_output_over_the_cap_is_refused_not_buffered(self, repo, monkeypatch): + monkeypatch.setattr(gb, "MAX_LIST_OUTPUT", 64) + client = await _client(repo) + try: + resp = await client.get("/api/project/git/branches", params={"path": str(repo)}) + body = await resp.json() + finally: + await client.close() + assert resp.status == 503 + assert body["code"] == "git_branches_unavailable" + + +class TestSwitch: + @pytest.mark.asyncio + async def test_switch_does_not_update_submodules_when_recurse_is_configured( + self, repo, tmp_path, monkeypatch + ): + source = tmp_path / "submodule-source" + source.mkdir() + _git("init", "-q", "-b", "main", cwd=source) + (source / "content.txt").write_text("initial\n") + _git("add", "content.txt", cwd=source) + _git("commit", "-q", "-m", "initial submodule", cwd=source) + initial = _git("rev-parse", "HEAD", cwd=source) + _git("-c", "protocol.file.allow=always", "submodule", "add", str(source), "child", cwd=repo) + _git("commit", "-q", "-am", "add submodule", cwd=repo) + _git("switch", "-q", "-c", "sub-update", cwd=repo) + child = repo / "child" + (child / "content.txt").write_text("updated\n") + _git("commit", "-q", "-am", "update submodule", cwd=child) + updated = _git("rev-parse", "HEAD", cwd=child) + _git("commit", "-q", "-am", "update gitlink", cwd=repo) + _git("switch", "-q", "--no-recurse-submodules", "main", cwd=repo) + _git("switch", "-q", "--detach", initial, cwd=child) + _git("config", "submodule.recurse", "true", cwd=repo) + seen = [] + real = gb._run_git + + def recording(args, cwd, **kwargs): + seen.append(args) + return real(args, cwd, **kwargs) + + monkeypatch.setattr(gb, "_run_git", recording) + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="sub-update") + finally: + await client.close() + assert status == 200, body + switches = [args for args in seen if args[0] == "switch"] + assert len(switches) == 1 + assert "--no-recurse-submodules" in switches[0] + assert _git("rev-parse", "HEAD:child", cwd=repo) == updated + assert _git("rev-parse", "HEAD", cwd=child) == initial + assert (child / "content.txt").read_text() == "initial\n" + + @pytest.mark.asyncio + async def test_switches_to_existing_local_branch(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 200, body + assert body == {"ok": True, "branch": "feature"} + assert _current(repo) == "feature" + assert (repo / "README.md").read_text() == "feature\n" + + @pytest.mark.asyncio + async def test_creates_branch_at_head_without_upstream(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feat/new-thing", create=True) + finally: + await client.close() + assert status == 200, body + assert _current(repo) == "feat/new-thing" + assert _git("rev-parse", "HEAD", cwd=repo) == _git("rev-parse", "main", cwd=repo) + with pytest.raises(subprocess.CalledProcessError): + _git("rev-parse", "--abbrev-ref", "feat/new-thing@{upstream}", cwd=repo) + + @pytest.mark.asyncio + async def test_create_refuses_an_existing_name(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature", create=True) + finally: + await client.close() + assert status == 409 + assert body["code"] == "git_branch_exists" + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_tracks_a_remote_branch(self, repo): + client = await _client(repo) + try: + status, body = await _switch( + client, repo, branch="remote-only", track="origin/remote-only" + ) + finally: + await client.close() + assert status == 200, body + assert _current(repo) == "remote-only" + assert ( + _git("rev-parse", "--abbrev-ref", "remote-only@{upstream}", cwd=repo) + == "origin/remote-only" + ) + + @pytest.mark.asyncio + async def test_missing_branch_is_404(self, repo): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="nope") + track_status, track_body = await _switch( + client, repo, branch="nope", track="origin/nope" + ) + finally: + await client.close() + assert (status, body["code"]) == (404, "git_branch_not_found") + assert (track_status, track_body["code"]) == (404, "git_branch_not_found") + + @pytest.mark.asyncio + async def test_conflicting_local_changes_are_refused_and_kept(self, repo): + (repo / "README.md").write_text("my edit\n") + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 409 + assert body["code"] == "git_switch_dirty" + # The refusal names the file that blocks the switch. + assert body["files"] == ["README.md"] + assert body["files_more"] == 0 + assert _current(repo) == "main" + assert (repo / "README.md").read_text() == "my edit\n" + + @pytest.mark.asyncio + async def test_ignored_local_file_the_target_tracks_is_refused_and_kept(self, repo): + # `tracked-there` tracks secret.env; on main it is ignored and holds + # local content git's default switch would silently replace. + _git("switch", "-q", "-c", "tracked-there", cwd=repo) + (repo / "secret.env").write_text("branch copy\n") + _git("add", "secret.env", cwd=repo) + _git("commit", "-q", "-m", "track secret.env", cwd=repo) + _git("switch", "-q", "main", cwd=repo) + (repo / ".git" / "info" / "exclude").write_text("secret.env\n") + (repo / "secret.env").write_text("my local value\n") + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="tracked-there") + finally: + await client.close() + assert status == 409 + assert body["code"] == "git_switch_dirty" + assert body["files"] == ["secret.env"] + assert _current(repo) == "main" + assert (repo / "secret.env").read_text() == "my local value\n" + + @pytest.mark.asyncio + async def test_every_git_call_runs_in_the_c_locale(self, repo, monkeypatch): + # The classifier reads English stderr; a translated user locale would + # turn a dirty refusal into a generic failure. + seen: list[bool] = [] + real = gb._run_git + + def recording(args, cwd, **kwargs): + seen.append(bool(kwargs.get("c_locale"))) + return real(args, cwd, **kwargs) + + monkeypatch.setattr(gb, "_run_git", recording) + (repo / "README.md").write_text("my edit\n") + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert (status, body["code"]) == (409, "git_switch_dirty") + assert seen + assert all(seen) + + @pytest.mark.asyncio + async def test_non_conflicting_local_changes_carry_over(self, repo): + (repo / "notes.txt").write_text("scratch\n") + client = await _client(repo) + try: + status, _ = await _switch(client, repo, branch="older") + finally: + await client.close() + assert status == 200 + assert _current(repo) == "older" + assert (repo / "notes.txt").read_text() == "scratch\n" + + @pytest.mark.asyncio + @pytest.mark.parametrize("same_named_tag", [False, True]) + async def test_switching_to_current_branch_is_a_no_op(self, repo, monkeypatch, same_named_tag): + if same_named_tag: + _git("tag", "main", cwd=repo) + (repo / "README.md").write_text("my edit\n") + seen = [] + real = gb._run_git + + def recording(args, cwd, **kwargs): + seen.append((args, kwargs)) + return real(args, cwd, **kwargs) + + monkeypatch.setattr(gb, "_run_git", recording) + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="main") + finally: + await client.close() + assert (status, body) == (200, {"ok": True, "branch": "main"}) + assert (["symbolic-ref", "--quiet", "HEAD"], {"c_locale": True}) in seen + assert not any(args[0] in ("switch", "branch") for args, _ in seen) + assert (repo / "README.md").read_text() == "my edit\n" + + @pytest.mark.asyncio + @pytest.mark.parametrize("branch", ["-f", "-", "@{-1}", "a b", "x\ny", ""]) + async def test_option_and_shorthand_names_are_refused(self, repo, branch): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch=branch) + finally: + await client.close() + assert status == 400 + assert body["code"] == "invalid_branch" + assert _current(repo) == "main" + + @pytest.mark.asyncio + @pytest.mark.parametrize("branch", ["foo..bar", "foo.lock", "HEAD", "feat/", "a~1"]) + async def test_create_requires_a_valid_new_name(self, repo, branch): + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch=branch, create=True) + finally: + await client.close() + assert status == 400 + assert body["code"] == "invalid_branch" + + @pytest.mark.asyncio + async def test_create_and_track_together_are_refused(self, repo): + client = await _client(repo) + try: + status, body = await _switch( + client, repo, branch="x", create=True, track="origin/remote-only" + ) + finally: + await client.close() + assert status == 400 + assert body["code"] == "invalid_input" + + @pytest.mark.asyncio + async def test_non_owner_is_refused_before_git_runs(self, repo, monkeypatch): + calls: list = [] + monkeypatch.setattr(gb, "_switch_sync", lambda *a: calls.append(a) or ({}, 200)) + client = await _client(repo, user="someone-else") + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status in (401, 403) + assert calls == [] + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_repo_root_above_the_granted_directory_is_refused(self, repo): + sub = repo / "sub" + sub.mkdir() + client = await _client(sub) + try: + status, body = await _switch(client, sub, branch="feature") + finally: + await client.close() + assert status == 403 + assert body["code"] == "repo_root_outside_project" + assert _current(repo) == "main" + + @pytest.mark.asyncio + async def test_work_tree_redirected_outside_the_project_is_refused(self, repo, tmp_path): + """``core.worktree`` would make the checkout write files somewhere else.""" + decoy = tmp_path / "decoy" + decoy.mkdir() + (decoy / "README.md").write_text("not yours\n") + _git("config", "core.worktree", str(decoy), cwd=repo) + client = await _client(repo) + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 403 + assert body["code"] == "repo_root_outside_project" + assert (decoy / "README.md").read_text() == "not yours\n" + + @pytest.mark.asyncio + async def test_unknown_project_is_refused(self, repo, tmp_path): + client = await _client(tmp_path / "elsewhere") + try: + status, body = await _switch(client, repo, branch="feature") + finally: + await client.close() + assert status == 403 + assert body["code"] == "unknown_project_dir" + + +class TestPlainRefName: + @pytest.mark.parametrize("name", ["main", "feat/x", "release-1.2", "user@host/x"]) + def test_accepts_ordinary_names(self, name): + assert gb._is_plain_ref_name(name) + + @pytest.mark.parametrize("name", ["", "-x", "-", "@{-1}", "a b", "a\tb", "x" * 201]) + def test_rejects_options_shorthands_and_whitespace(self, name): + assert not gb._is_plain_ref_name(name) diff --git a/test/test_project_git_branches_coverage.py b/test/test_project_git_branches_coverage.py new file mode 100644 index 00000000000..917680bcd92 --- /dev/null +++ b/test/test_project_git_branches_coverage.py @@ -0,0 +1,810 @@ +"""Sandbox-free coverage of branch listing, switching, and refusal paths. + +Git subprocess seams are replaced by table-driven responses. The real sandbox +launcher is unavailable throughout these tests, so coverage does not depend on +namespace isolation or on a Git executable. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import subprocess +import threading +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp import web + +from kiro_crew import repo_checkout_guard +from kiro_crew.dashboard.handlers import git_branches as gb +from kiro_crew.dashboard.handlers import worktree as wt +from kiro_crew.testing.wait import async_wait_until, await_until, default_timeout + +_TOKEN = "ghp_" + "a1B2c3" * 6 + + +class FakeGit: + """Map argv prefixes to completed processes or raised exceptions.""" + + def __init__(self, root): + self.table = { + ("rev-parse", "--show-toplevel"): (0, str(root), ""), + ("branch", "--show-current"): (0, "main\n", ""), + ("symbolic-ref", "--quiet", "HEAD"): (0, "refs/heads/main\n", ""), + } + self.refs = {} + self.calls = [] + + def __call__(self, args, cwd, **kwargs): + self.calls.append((tuple(args), cwd, kwargs)) + for prefix, result in self.table.items(): + if tuple(args[: len(prefix)]) == prefix: + if isinstance(result, Exception): + raise result + return subprocess.CompletedProcess(args, *result) + return subprocess.CompletedProcess(args, 0, "", "") + + def bounded(self, args, **kwargs): + self.calls.append((tuple(args), kwargs["cwd"], kwargs)) + return self.refs.get(args[-1], (0, "", False)) + + +@pytest.fixture(autouse=True) +def unavailable_sandbox(_floor_monkeypatch): + launcher = MagicMock(side_effect=RuntimeError("sandbox unavailable")) + _floor_monkeypatch.setattr(wt, "sandboxed_spawn_argv", launcher) + return launcher + + +@pytest.fixture +def boundary(monkeypatch, tmp_path): + root = str(tmp_path) + git = FakeGit(root) + monkeypatch.setattr(gb, "_run_git", git) + monkeypatch.setattr(gb, "_run_git_bounded", git.bounded) + monkeypatch.setattr(gb, "_allowed_repo_roots", lambda state: [root]) + monkeypatch.setattr(gb, "_checkout_filter", lambda root: "") + monkeypatch.setattr(gb, "_resolve_commit", lambda root, ref: "abc123") + monkeypatch.setattr(gb, "is_sensitive_path", lambda path: False) + monkeypatch.setattr(gb, "sel", MagicMock()) + monkeypatch.setattr(gb, "deny_non_dashboard_caller", lambda *args: None) + monkeypatch.setattr(gb, "require_owner_dashboard_request", AsyncMock(return_value=None)) + body = {"path": root, "branch": "feature"} + monkeypatch.setattr(gb, "read_bounded_json", AsyncMock(return_value=(body, None))) + request = SimpleNamespace(app={"state": None}, query={"path": root}, get=lambda key: "owner") + return SimpleNamespace(root=root, git=git, body=body, request=request) + + +def _row(name="feature", **extra): + return { + "name": name, + "date": "today", + "author": "Test", + "subject": "work", + **extra, + } + + +def _ref(name, track="", head=""): + return gb._SEP.join((name, "today", "Test", "work", track, head)) + + +def _body(response): + return json.loads(response.text) + + +def test_real_git_boundary_refuses_without_sandbox(boundary, unavailable_sandbox): + with pytest.raises(gb.SandboxUnavailable, match="sandbox unavailable"): + wt._run_git(["--version"], boundary.root) + unavailable_sandbox.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "case,status,code", + [ + ("missing", 400, "path_required"), + ("nonstring", 400, "path_required"), + ("unknown", 403, "unknown_project_dir"), + ("sensitive", 403, "access_denied"), + ("not-directory", 404, "not_a_repository"), + ("sandbox", 503, "git_sandbox_unavailable"), + ("os-error", 503, "git_unavailable"), + ("subprocess-error", 503, "git_unavailable"), + ("not-repo", 404, "not_a_repository"), + ("probe-failure", 503, "git_branches_unavailable"), + ("empty-top", 503, "git_branches_unavailable"), + ("outside-root", 403, "repo_root_outside_project"), + ("sensitive-top", 403, "repo_root_outside_project"), + ], +) +async def test_resolve_refusals_and_listing_mapping(boundary, monkeypatch, case, status, code): + raw = boundary.root + if case == "missing": + raw = " " + elif case == "nonstring": + raw = None + elif case == "unknown": + monkeypatch.setattr(gb, "_allowed_repo_roots", lambda state: []) + elif case == "sensitive": + monkeypatch.setattr(gb, "is_sensitive_path", lambda path: True) + elif case == "not-directory": + monkeypatch.setattr(gb.os.path, "isdir", lambda path: False) + else: + results = { + "sandbox": gb.SandboxUnavailable("unavailable"), + "os-error": OSError("unavailable"), + "subprocess-error": subprocess.SubprocessError("unavailable"), + "not-repo": (128, "", "fatal: not a git repository (or any parent): .git"), + "probe-failure": (128, "", "fatal: dubious ownership"), + "empty-top": (0, " \n", ""), + "outside-root": (0, str(gb.os.path.dirname(boundary.root)), ""), + "sensitive-top": (0, os.path.join(boundary.root, "sensitive"), ""), + } + boundary.git.table[("rev-parse", "--show-toplevel")] = results[case] + if case == "sensitive-top": + monkeypatch.setattr( + gb, "is_sensitive_path", lambda path: os.path.basename(path) == "sensitive" + ) + with pytest.raises(gb._Refusal) as caught: + await gb._resolve_repo_root(boundary.request, raw, "test") + assert (caught.value.status, caught.value.body["code"]) == (status, code) + boundary.request.query["path"] = raw + response = await gb.api_project_git_branches(boundary.request) + if code == "not_a_repository": + assert response.status == 200 + assert _body(response) == {"repo": False, "local": [], "remote": []} + else: + assert (response.status, _body(response)["code"]) == (status, code) + + +@pytest.mark.asyncio +async def test_resolve_uses_server_root_for_subdirectory(boundary): + raw = boundary.root + "/nested/../nested" + assert await gb._resolve_repo_root(boundary.request, raw, "test") == boundary.root + assert boundary.git.calls[0][1] == boundary.root + + +@pytest.mark.asyncio +@pytest.mark.parametrize("order", ["parent-first", "nested-first"]) +async def test_a_nested_project_resolves_to_itself_not_its_parent(boundary, monkeypatch, order): + """One chat's project is a parent repository and another's a repository + nested in it: the nested picker must read and switch the nested repository.""" + nested = os.path.join(boundary.root, "nested") + os.makedirs(nested) + roots = [boundary.root, nested] if order == "parent-first" else [nested, boundary.root] + monkeypatch.setattr(gb, "_allowed_repo_roots", lambda state: roots) + boundary.git.table[("rev-parse", "--show-toplevel")] = (0, nested, "") + assert await gb._resolve_repo_root(boundary.request, nested, "test") == nested + assert boundary.git.calls[0][1] == nested + # A folder inside the nested project resolves through the nested one too. + inside = os.path.join(nested, "src") + assert gb._closest_allowed_root(inside, roots) == nested + assert gb._closest_allowed_root(boundary.root, roots) == boundary.root + assert gb._closest_allowed_root(boundary.root + "-evil", roots) is None + + +@pytest.mark.asyncio +async def test_a_switch_for_a_nested_project_no_chat_holds_is_refused_not_redirected( + boundary, monkeypatch +): + """The chat moved from nested repository A to its parent B while A's switch + was in flight: A is not any chat's project, so the switch is refused + rather than resolved to B by containment and run against B.""" + nested = os.path.join(boundary.root, "nested") + os.makedirs(nested) + monkeypatch.setattr(gb, "_allowed_repo_roots", lambda state: [boundary.root]) + boundary.body.update({"path": nested, "branch": "feature"}) + response = await gb.api_project_git_switch(boundary.request) + assert (response.status, _body(response)["code"]) == (403, "unknown_project_dir") + # Refused before any git runs, in either repository. + assert boundary.git.calls == [] + # The read-only listing still reads a folder inside a chat project. + assert await gb._resolve_repo_root(boundary.request, nested, "test") == boundary.root + # The exact project, with or without a trailing separator, is accepted. + assert gb._exact_allowed_root(boundary.root + os.sep, [boundary.root]) == boundary.root + assert gb._exact_allowed_root(nested, [boundary.root]) is None + + +def test_list_refs_parses_tracking_and_skips_malformed_rows(boundary): + lines = [ + _ref("refs/heads/main", "ahead 3, behind 4", "*"), + _ref("refs/heads/feature", "gone"), + _ref("refs/heads/plain"), + "malformed", + _ref("refs/tags/tag"), + ] + boundary.git.refs["refs/heads"] = (0, "\n".join(lines), False) + rows, truncated = gb._list_refs(boundary.root, "refs/heads") + assert not truncated + assert len(rows) == 3 + assert rows[0] == _row("main", current=True, ahead=3, behind=4) + assert rows[1] == _row("feature", current=False) + assert rows[2] == _row("plain", current=False) + argv, root, kwargs = boundary.git.calls[-1] + assert "--count=201" in argv and argv[-1] == "refs/heads" + assert f"--format={gb._FORMAT_ARG}" in argv + assert "%(upstream:track,nobracket)" in gb._FORMAT_ARG + assert "%(objectname:short)" not in gb._FORMAT_ARG + assert "%(upstream:short)" not in gb._FORMAT_ARG + assert root == boundary.root + assert kwargs["cap"] == gb.MAX_LIST_OUTPUT + assert kwargs["env"]["LC_ALL"] == "C" + boundary.git.refs["refs/remotes"] = ( + 0, + _ref("refs/remotes/origin/HEAD") + "\n" + _ref("refs/remotes/origin/other"), + False, + ) + assert gb._list_refs(boundary.root, "refs/remotes") == ([_row("origin/other")], False) + + +@pytest.mark.parametrize("result", [(1, "", False), (0, "", True)]) +def test_list_refs_failures(boundary, result): + boundary.git.refs["refs/heads"] = result + assert gb._list_refs(boundary.root, "refs/heads") is None + + +@pytest.mark.parametrize("symref", [False, True]) +def test_list_refs_truncation_includes_skipped_symref(boundary, monkeypatch, symref): + monkeypatch.setattr(gb, "MAX_BRANCH_ROWS", 2) + lines = [_ref(f"refs/remotes/origin/{name}") for name in ("a", "b", "HEAD" if symref else "c")] + boundary.git.refs["refs/remotes"] = (0, "\n".join(lines), False) + rows, truncated = gb._list_refs(boundary.root, "refs/remotes") + assert len(rows) == 2 and truncated + + +@pytest.mark.parametrize("failed", ["current", "local", "remote"]) +def test_branches_sync_failures(boundary, failed): + if failed == "current": + boundary.git.table[("branch", "--show-current")] = (1, "", "failed") + else: + namespace = "refs/heads" if failed == "local" else "refs/remotes" + boundary.git.refs[namespace] = (1, "", False) + body, status = gb._branches_sync(boundary.root) + assert (status, body["code"]) == (503, "git_branches_unavailable") + + +@pytest.mark.parametrize("head_result", [(0, "abc123\n", ""), (1, "", ""), (0, "", "")]) +def test_branches_detached_filter_truncation_and_remote_twins(boundary, monkeypatch, head_result): + boundary.git.table[("branch", "--show-current")] = (0, "", "") + boundary.git.table[("rev-parse", "--short", "HEAD")] = head_result + monkeypatch.setattr( + gb, + "_list_refs", + lambda root, ns: ( + ([_row("feature")], True) + if ns == "refs/heads" + else ([_row("origin/feature"), _row("origin/other")], False) + ), + ) + monkeypatch.setattr(gb, "_checkout_filter", lambda root: "filter.example.smudge") + body, status = gb._branches_sync(boundary.root) + assert status == 200 and body["current"] is None + assert body["remote"] == [_row("origin/other")] + assert body["truncated"] and body["switchBlocked"] == "filter" + assert body.get("detached", False) == bool(head_result[1]) + if head_result[1]: + assert body["head"] == "abc123" + + +@pytest.mark.parametrize( + "names_result,listed", + [ + ((0, "feature\nold-twin\n", False), ["origin/other"]), + ((1, "", False), ["origin/old-twin", "origin/other"]), + ((0, "x\n", True), ["origin/old-twin", "origin/other"]), + ], +) +def test_a_capped_local_list_still_hides_an_older_local_twin( + boundary, monkeypatch, names_result, listed +): + monkeypatch.setattr( + gb, + "_list_refs", + lambda root, ns: ( + ([_row("feature")], True) + if ns == "refs/heads" + else ([_row("origin/old-twin"), _row("origin/other")], False) + ), + ) + boundary.git.refs["refs/heads"] = names_result + body, status = gb._branches_sync(boundary.root) + assert status == 200 + assert [r["name"] for r in body["remote"]] == listed + + +def test_an_uncapped_local_list_reads_no_extra_names(boundary, monkeypatch): + monkeypatch.setattr( + gb, + "_list_refs", + lambda root, ns: ( + ([_row("feature")], False) if ns == "refs/heads" else ([_row("origin/x")], False) + ), + ) + gb._branches_sync(boundary.root) + assert not any("--format=%(refname:lstrip=2)" in call[0] for call in boundary.git.calls) + + +@pytest.mark.parametrize("remote", [False, True]) +@pytest.mark.parametrize( + "name,valid", + [("feat/x", True), ("feat+1", False), ("Fix#12", False), ("_wip", False), ("x" * 201, False)], +) +def test_redacted_rows_remote_creation_grammar(remote, name, valid): + row = _row("origin/" + name if remote else name) + gb._redact_rows([row], remote=remote) + assert row["switchable"] == (valid if remote else len(name) <= gb.MAX_BRANCH_NAME) + assert len(row["name"]) <= gb.MAX_BRANCH_NAME + + +def test_redacted_rows_sanitize_before_clipping(): + row = _row( + "feat/" + _TOKEN, + author=_TOKEN + "a" * 500, + subject="s" * 500, + ) + gb._redact_rows([row]) + assert not row["switchable"] and _TOKEN not in str(row) + assert all( + len(row[key]) <= limit + for key, limit in ( + ("name", gb.MAX_BRANCH_NAME), + ("author", gb.MAX_ROW_TEXT), + ("subject", gb.MAX_ROW_TEXT), + ) + ) + assert row["subject"].endswith("\u2026") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("detached", [False, True]) +async def test_listing_redacts_current_head_and_remote_validation(boundary, monkeypatch, detached): + payload = { + "repo": True, + "current": None if detached else _TOKEN, + "local": [_row()], + "remote": [_row("origin/feat+1")], + } + if detached: + payload["head"] = _TOKEN + monkeypatch.setattr(gb, "_branches_sync", lambda root: (payload, 200)) + response = await gb.api_project_git_branches(boundary.request) + assert response.status == 200 and _TOKEN not in response.text + assert not _body(response)["remote"][0]["switchable"] + + +_EXCEPTIONS = [ + (gb.SandboxUnavailable("no sandbox"), 503, "git_sandbox_unavailable"), + (subprocess.TimeoutExpired("git", 10), 504, "git_timeout"), + (OSError("unavailable"), 503, "git_branches_unavailable"), + (subprocess.SubprocessError("failed"), 503, "git_branches_unavailable"), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exc,status,code", _EXCEPTIONS) +async def test_listing_exception_mapping(boundary, exc, status, code): + boundary.git.table[("branch", "--show-current")] = exc + response = await gb.api_project_git_branches(boundary.request) + assert (response.status, _body(response)["code"]) == (status, code) + + +@pytest.mark.asyncio +async def test_listing_sync_error_response(boundary): + boundary.git.table[("branch", "--show-current")] = (1, "", "failed") + response = await gb.api_project_git_branches(boundary.request) + assert (response.status, _body(response)["code"]) == (503, "git_branches_unavailable") + + +@pytest.mark.parametrize("name", ["main", "feat/x", "user@host/x", "feat+1"]) +def test_plain_ref_names(name): + assert gb._is_plain_ref_name(name) + + +@pytest.mark.parametrize( + "name", ["", "-x", "-", "@{-1}", "a b", "a\tb", "a\nb", "a\x01b", "a\x7fb", "x" * 201] +) +def test_ambiguous_ref_names(name): + assert not gb._is_plain_ref_name(name) + + +@pytest.mark.parametrize("mode", ["existing", "create", "track"]) +def test_switch_argv_disables_submodule_recursion(boundary, monkeypatch, mode): + monkeypatch.setattr( + gb, "_resolve_commit", lambda root, ref: "" if mode == "create" else "abc123" + ) + body, status = gb._switch_sync( + boundary.root, "feature", mode == "create", "origin/feature" if mode == "track" else "" + ) + assert (status, body) == (200, {"ok": True, "branch": "feature"}) + assert not any(argv[0] == "branch" for argv, _, _ in boundary.git.calls) + assert any(argv[0] == "symbolic-ref" for argv, _, _ in boundary.git.calls) == ( + mode == "existing" + ) + switches = [argv for argv, _, _ in boundary.git.calls if argv[0] == "switch"] + expected = ["switch", "--no-recurse-submodules", "--no-overwrite-ignore"] + if mode == "existing": + expected += ["--no-guess", "--", "feature"] + elif mode == "create": + expected += ["--no-track", "-c", "feature"] + else: + expected += ["-c", "feature", "--track", "refs/remotes/origin/feature"] + assert switches == [tuple(expected)] + assert all(kwargs["c_locale"] is True for _, _, kwargs in boundary.git.calls) + + +@pytest.mark.parametrize( + "case,status,code", + [ + ("filter", 409, "git_switch_filter_refused"), + ("format", 400, "invalid_branch"), + ("missing", 404, "git_branch_not_found"), + ("remote-missing", 404, "git_branch_not_found"), + ("exists", 409, "git_branch_exists"), + ], +) +def test_switch_preflight_refusals(boundary, monkeypatch, case, status, code): + if case == "filter": + monkeypatch.setattr(gb, "_checkout_filter", lambda root: "filter.example.smudge") + if case == "format": + boundary.git.table[("check-ref-format",)] = (1, "", "invalid") + if "missing" in case: + monkeypatch.setattr(gb, "_resolve_commit", lambda root, ref: "") + body, actual = gb._switch_sync( + boundary.root, + "feature", + case == "exists", + "origin/feature" if case == "remote-missing" else "", + ) + assert (actual, body["code"]) == (status, code) + assert not any(argv[0] == "switch" for argv, _, _ in boundary.git.calls) + + +@pytest.mark.parametrize("current", [(0, "refs/heads/main", ""), (1, "", ""), (0, "", "")]) +def test_switch_current_noop_and_detached_head(boundary, current): + boundary.git.table[("symbolic-ref", "--quiet", "HEAD")] = current + body, status = gb._switch_sync(boundary.root, "main", False, "") + assert (status, body) == (200, {"ok": True, "branch": "main"}) + assert any(argv[0] == "switch" for argv, _, _ in boundary.git.calls) == (not current[1]) + assert not any(argv[0] == "branch" for argv, _, _ in boundary.git.calls) + assert all(kwargs["c_locale"] is True for _, _, kwargs in boundary.git.calls) + + +@pytest.mark.parametrize("fragment,code,status,message", gb._SWITCH_FAILURES) +def test_switch_known_failures(boundary, fragment, code, status, message): + boundary.git.table[("switch",)] = (1, "", "fatal: " + fragment.upper()) + body, actual = gb._switch_sync(boundary.root, "feature", False, "") + assert (actual, body) == (status, {"error": message, "code": code}) + + +def test_switch_untracked_refusal_names_untracked_files(boundary): + # git's own text, which also contains the uncommitted entry's fragment. + boundary.git.table[("switch",)] = ( + 1, + "", + "error: The following untracked working tree files would be overwritten by checkout:\n" + "\tnotes.txt\nPlease move or remove them before you switch branches.\nAborting\n", + ) + body, status = gb._switch_sync(boundary.root, "feature", False, "") + assert status == 409 and body["code"] == "git_switch_dirty" + assert body["error"].startswith("Untracked files would be overwritten") + + +@pytest.mark.parametrize( + "stderr,stdout,detail", + [ + ("\n first line \nsecond", "", "first line"), + ("", "stdout diagnostic", "stdout diagnostic"), + (" \n\t", "", ""), + ("x" * 400, "", "x" * 300), + ], +) +def test_switch_generic_failure_detail(boundary, stderr, stdout, detail): + boundary.git.table[("switch",)] = (1, stdout, stderr) + body, status = gb._switch_sync(boundary.root, "feature", False, "") + assert status == 400 and body["code"] == "git_switch_failed" and body["detail"] == detail + + +@pytest.mark.asyncio +async def test_full_diagnostic_redacted_before_cut_and_at_response(boundary): + prefix = "fatal: " + "x" * 278 + diagnostic = prefix + _TOKEN + "/lock: cannot create\nsecond line" + assert diagnostic.index(_TOKEN) < 300 < diagnostic.index(_TOKEN) + len(_TOKEN) + assert _TOKEN[:15] in diagnostic[:300] + assert gb.redact(diagnostic[:300]) == diagnostic[:300] + boundary.git.table[("switch",)] = (1, "", diagnostic) + payload, status = gb._switch_sync(boundary.root, "feature", False, "") + assert status == 400 and _TOKEN[:15] not in payload["detail"] + assert "REDACTED" in payload["detail"] and len(payload["detail"]) <= 300 + response = await gb.api_project_git_switch(boundary.request) + assert response.status == 400 and _body(response)["detail"] == payload["detail"] + assert gb.redact(payload["detail"]) == payload["detail"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "patch,code", + [ + ({"branch": None}, "invalid_input"), + ({"branch": 3}, "invalid_input"), + ({"create": 1}, "invalid_input"), + ({"track": None}, "invalid_input"), + ({"create": True, "track": "origin/x"}, "invalid_input"), + ({"branch": "-f"}, "invalid_branch"), + ({"branch": ""}, "invalid_branch"), + ({"branch": "x" * 201}, "invalid_branch"), + ({"branch": "feat+1", "create": True}, "invalid_branch"), + ({"branch": "feat+1", "track": "origin/feat+1"}, "invalid_branch"), + ({"track": "@{-1}"}, "invalid_branch"), + ], +) +async def test_switch_input_validation(boundary, patch, code): + boundary.body.update(patch) + response = await gb.api_project_git_switch(boundary.request) + assert (response.status, _body(response)["code"]) == (400, code) + assert boundary.git.calls == [] + + +@pytest.mark.asyncio +async def test_switch_resolver_refusal(boundary): + boundary.body["path"] = None + response = await gb.api_project_git_switch(boundary.request) + assert (response.status, _body(response)["code"]) == (400, "path_required") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "exc,status,code", + [ + (subprocess.TimeoutExpired("git", 10), 504, "git_timeout"), + (gb.SandboxUnavailable("unavailable"), 503, "git_sandbox_unavailable"), + (OSError("unavailable"), 500, "git_switch_failed"), + (subprocess.SubprocessError("unavailable"), 500, "git_switch_failed"), + ], +) +async def test_switch_exception_mapping(boundary, exc, status, code): + boundary.git.table[("switch",)] = exc + response = await gb.api_project_git_switch(boundary.request) + assert (response.status, _body(response)["code"]) == (status, code) + + +@pytest.mark.asyncio +async def test_switch_classified_error_without_detail(boundary): + boundary.git.table[("switch",)] = (1, "", "branch already exists") + response = await gb.api_project_git_switch(boundary.request) + assert (response.status, _body(response)["code"]) == (409, "git_branch_exists") + assert "detail" not in _body(response) + + +@pytest.mark.asyncio +async def test_switch_success_redacts_response_and_strips_input(boundary, monkeypatch): + boundary.body.update(branch=" feature ", track=" origin/feature ") + switch = MagicMock(return_value=({"ok": True, "branch": _TOKEN}, 200)) + monkeypatch.setattr(gb, "_switch_sync", switch) + response = await gb.api_project_git_switch(boundary.request) + switch.assert_called_once_with(boundary.root, "feature", False, "origin/feature") + assert response.status == 200 and _TOKEN not in response.text + assert _body(response) == {"ok": True, "branch": gb.redact(_TOKEN)} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("gate", ["listing", "owner", "body"]) +async def test_route_gates_return_before_git(boundary, monkeypatch, gate): + denied = web.json_response({"error": "denied"}, status=403) + if gate == "listing": + monkeypatch.setattr(gb, "deny_non_dashboard_caller", lambda *args: denied) + response = await gb.api_project_git_branches(boundary.request) + else: + if gate == "owner": + monkeypatch.setattr( + gb, "require_owner_dashboard_request", AsyncMock(return_value=denied) + ) + else: + monkeypatch.setattr(gb, "read_bounded_json", AsyncMock(return_value=(None, denied))) + response = await gb.api_project_git_switch(boundary.request) + assert response is denied and boundary.git.calls == [] + + +@pytest.mark.parametrize("c_locale", [True, False]) +def test_run_git_sets_the_c_locale_only_when_asked(_floor_monkeypatch, tmp_path, c_locale): + seen: dict = {} + + def fake_spawn(argv, mode="standard", **kwargs): + return list(argv), {"LC_ALL": "fr_FR.UTF-8"}, None + + def fake_run(argv, **kwargs): + seen["env"] = kwargs["env"] + return subprocess.CompletedProcess(argv, 0, b"", b"") + + _floor_monkeypatch.setattr(wt, "sandboxed_spawn_argv", fake_spawn) + _floor_monkeypatch.setattr(wt.subprocess, "run", fake_run) + wt._run_git(["status"], str(tmp_path), c_locale=c_locale) + if c_locale: + assert (seen["env"]["LC_ALL"], seen["env"]["LANGUAGE"]) == ("C", "C") + else: + assert seen["env"]["LC_ALL"] == "fr_FR.UTF-8" + assert "LANGUAGE" not in seen["env"] + + +class _Subagents: + def __init__(self, pending=(), error=False): + self.pending = set(pending) + self.error = error + self.asked: list[str] = [] + + async def has_pending_work_for_async(self, key): + self.asked.append(key) + if self.error: + raise RuntimeError("store unavailable") + return key in self.pending + + +def _slot(project, key, running=False): + return SimpleNamespace(project=str(project), key=key, running=running, session_key="") + + +def test_slots_in_repo_matches_root_inside_and_containing_folders(tmp_path): + repo = tmp_path / "repo" + (repo / "sub").mkdir(parents=True) + other = tmp_path / "other" + other.mkdir() + state = SimpleNamespace( + _slots={ + "a": _slot(repo, "a"), + "b": _slot(repo / "sub", "b"), + "c": _slot(tmp_path, "c"), + "d": _slot(other, "d"), + "e": _slot("", "e"), + } + ) + keys = [s.key for s in gb._slots_in_repo(state, os.path.realpath(repo))] + assert keys == ["a", "b", "c"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("running", "subagents", "expected"), + [ + (False, None, False), + (True, None, True), + (False, _Subagents(), False), + (False, _Subagents(pending={"dashboard:busy"}), True), + (False, _Subagents(error=True), True), + ], +) +async def test_repo_has_running_work(tmp_path, monkeypatch, running, subagents, expected): + from kiro_crew.dashboard import chat_utils + + monkeypatch.setattr(chat_utils, "effective_session_key", lambda slot: f"dashboard:{slot.key}") + state = SimpleNamespace(_slots={"busy": _slot(tmp_path, "busy", running)}, subagents=subagents) + assert await gb._repo_has_running_work(state, os.path.realpath(tmp_path)) is expected + + +@pytest.mark.asyncio +async def test_switch_refuses_while_a_session_in_the_repo_runs(boundary, monkeypatch): + monkeypatch.setattr(gb, "_repo_has_running_work", AsyncMock(return_value=True)) + response = await gb.api_project_git_switch(boundary.request) + assert response.status == 409 + assert json.loads(response.text)["code"] == "git_switch_session_busy" + assert not any(call[0][0] == "switch" for call in boundary.git.calls) + assert not repo_checkout_guard.is_reserved(boundary.root) + + +@pytest.fixture +def clean_reservations(): + repo_checkout_guard._reserved.clear() + yield + repo_checkout_guard._reserved.clear() + + +@pytest.mark.asyncio +async def test_switch_reserves_the_repo_before_the_busy_check( + boundary, monkeypatch, clean_reservations +): + seen: list[bool] = [] + + async def busy(state, root): + seen.append(repo_checkout_guard.is_reserved(root)) + return False + + monkeypatch.setattr(gb, "_repo_has_running_work", busy) + response = await gb.api_project_git_switch(boundary.request) + assert response.status == 200 + assert seen == [True] + assert not repo_checkout_guard.is_reserved(boundary.root) + + +@pytest.mark.asyncio +async def test_switch_refuses_while_another_switch_holds_the_repo( + boundary, monkeypatch, clean_reservations +): + busy = AsyncMock(return_value=False) + monkeypatch.setattr(gb, "_repo_has_running_work", busy) + repo_checkout_guard.reserve(boundary.root) + response = await gb.api_project_git_switch(boundary.request) + assert (response.status, _body(response)["code"]) == (409, "git_switch_session_busy") + busy.assert_not_awaited() + assert not any(call[0][0] == "switch" for call in boundary.git.calls) + assert repo_checkout_guard.is_reserved(boundary.root) + + +@pytest.mark.asyncio +async def test_a_cancelled_switch_keeps_the_reservation_until_git_exits( + boundary, monkeypatch, clean_reservations +): + monkeypatch.setattr(gb, "_repo_has_running_work", AsyncMock(return_value=False)) + started = threading.Event() + finish = threading.Event() + + def slow_switch(root, branch, create, track): + started.set() + finish.wait(default_timeout()) + return {"ok": True, "branch": branch}, 200 + + monkeypatch.setattr(gb, "_switch_sync", slow_switch) + holding = asyncio.Event() + real_wait = asyncio.wait + + async def signalling_wait(fs, *args, **kwargs): + holding.set() + return await real_wait(fs, *args, **kwargs) + + monkeypatch.setattr(gb.asyncio, "wait", signalling_wait) + request = asyncio.create_task(gb.api_project_git_switch(boundary.request)) + await async_wait_until(started.is_set, describe=lambda: "the checkout worker never started") + + request.cancel() + # Barrier: the cancel has reached the handler and it is parked in its + # ``asyncio.wait([worker])`` hold (the loop is single-threaded, so it is + # suspended there by the time this resumes). "Not done" therefore cannot + # pass for the trivial reason that the cancel was never delivered. + await await_until(holding) + assert not request.done() + assert repo_checkout_guard.is_reserved(boundary.root) + + finish.set() + with pytest.raises(asyncio.CancelledError): + await await_until(request) + assert not repo_checkout_guard.is_reserved(boundary.root) + + +class TestOverwrittenFiles: + def test_lists_tab_indented_paths_after_the_header(self): + stderr = ( + "error: Your local changes to the following files would be overwritten by checkout:\n" + "\tREADME.md\n" + "\tsrc/app.py\n" + "Please commit your changes or stash them before you switch branches.\n" + "Aborting\n" + ) + assert gb._overwritten_files(stderr) == (["README.md", "src/app.py"], 0) + + def test_reads_the_untracked_variant_too(self): + stderr = ( + "error: The following untracked working tree files would be overwritten by checkout:\n" + "\tnotes.txt\n" + "Please move or remove them before you switch branches.\n" + ) + assert gb._overwritten_files(stderr) == (["notes.txt"], 0) + + def test_caps_the_list_and_counts_the_rest(self): + names = [f"f{i}.txt" for i in range(gb.MAX_DIRTY_FILES + 3)] + stderr = ( + "error: x would be overwritten by checkout:\n" + + "".join(f"\t{n}\n" for n in names) + + "Aborting\n" + ) + files, more = gb._overwritten_files(stderr) + assert files == names[: gb.MAX_DIRTY_FILES] + assert more == 3 + + def test_redacts_each_path(self, monkeypatch): + monkeypatch.setattr(gb, "redact", lambda s: s.replace("hunter2", "[REDACTED]")) + stderr = "error: x would be overwritten by checkout:\n\tkeys/hunter2.txt\nAborting\n" + assert gb._overwritten_files(stderr) == (["keys/[REDACTED].txt"], 0) + + def test_no_header_lists_nothing(self): + assert gb._overwritten_files("fatal: something else\n\tnot-a-file\n") == ([], 0) diff --git a/test/test_prompt_checkout_seam.py b/test/test_prompt_checkout_seam.py new file mode 100644 index 00000000000..06b51873c9f --- /dev/null +++ b/test/test_prompt_checkout_seam.py @@ -0,0 +1,698 @@ +"""The branch switch's busy check sees every agent prompt, on both ACP transports. + +Every harness sends a turn as a ``session/prompt`` request: through +``AcpClient._send_request`` (the adapter harnesses) or ``AcpRuntime.send_request`` +(the shared kiro / KAS runtime). Each prompt is recorded from before its write +until the backend answers it or the process tree is confirmed gone +(``kiro_crew.repo_checkout_guard``), whatever drives the turn and whether or not +anyone still waits on the answer. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from acp_runtime_helpers import _feed, _make_runtime, _register + +from kiro_crew import repo_checkout_guard as guard +from kiro_crew.acp.client import AcpClient +from kiro_crew.acp.types import METHOD_COMMANDS_EXECUTE, METHOD_PROMPT +from kiro_crew.dashboard.handlers import git_branches as gb +from kiro_crew.testing.wait import async_wait_until, await_until + +SID = "sess-1" + + +@pytest.fixture(autouse=True) +def _no_reservations(): + guard._reserved.clear() + yield + guard._reserved.clear() + + +@pytest.fixture +def repo(tmp_path): + root = tmp_path / "repo" + (root / "sub").mkdir(parents=True) + (tmp_path / "other").mkdir() + return root + + +def _no_slots(): + return SimpleNamespace(_slots={}, subagents=None) + + +async def _busy(root) -> bool: + return await gb._repo_has_running_work(_no_slots(), os.path.realpath(root)) + + +def test_the_prompt_methods_are_the_wire_constants(): + from kiro_crew.acp.client_turn_control import _GOOSE_SESSION_STEER + from kiro_crew.acp.types import METHOD_SESSION_STEERING, STEERING_STARTED_NEW_TURN + + assert guard.TURN_METHODS == {METHOD_PROMPT, METHOD_COMMANDS_EXECUTE} + assert guard.STEER_METHODS == {"_session/steer", METHOD_SESSION_STEERING, _GOOSE_SESSION_STEER} + assert guard.PROMPT_METHODS == guard.TURN_METHODS | guard.STEER_METHODS + assert guard.STARTED_NEW_TURN == STEERING_STARTED_NEW_TURN + + +def test_no_checkout_in_flight_means_no_suspension(): + # A coroutine that completes on its first step never yields to the loop, so a + # prompt sent with no switch in progress is not delayed or reordered. + for folder in ("/some/where", ""): + step = guard.before_prompt(folder) + with pytest.raises(StopIteration): + step.send(None) + + +@pytest.mark.asyncio +async def test_a_switch_reserved_during_the_off_loop_match_is_waited_out( + repo, tmp_path, monkeypatch +): + other = os.path.realpath(tmp_path / "other") + root = os.path.realpath(repo) + real_to_thread = asyncio.to_thread + raced: list[str] = [] + + async def _match_then_race(fn, *args): + result = await real_to_thread(fn, *args) + if not raced: + # A switch of the prompt's own repo reserves while the match that + # found nothing covering it ran off the loop. + raced.append(root) + assert guard.reserve(root) + return result + + monkeypatch.setattr(asyncio, "to_thread", _match_then_race) + assert guard.reserve(other) + admitted = asyncio.ensure_future(guard.before_prompt(str(repo / "sub"))) + await async_wait_until(lambda: bool(raced)) + guard.release(other) + for _ in range(20): + await asyncio.sleep(0) + assert not admitted.done() + guard.release(root) + await await_until(admitted) + + +@pytest.mark.parametrize( + "answer, counted", + [ + ({"result": {"outcome": "injected"}}, False), + ({"result": {"queued": True}}, False), + ({"error": {"code": -32601}}, False), + # An adapter-owned turn nobody will report the end of. + ({"result": {"outcome": "startedNewTurn"}}, True), + ], + ids=["injected", "queued", "refused", "started-new-turn"], +) +@pytest.mark.parametrize("method", sorted(guard.STEER_METHODS)) +def test_a_steer_stays_counted_only_when_it_started_a_turn_of_its_own(method, answer, counted): + guard.prompt_sent("inst", 3, "/w", method=method) + guard.prompt_answered("inst", 3, answer.get("result")) + assert (guard.tracked_work_folders() == (["/w"], True)) is counted + guard.prompt_process_gone("inst") + assert guard.tracked_work_folders() == ([], True) + + +@pytest.mark.parametrize("wire_id", [1.0, 1.9, "1", True, None, [1]]) +def test_only_the_issued_integer_id_answers_a_prompt(wire_id): + guard.prompt_sent("inst", 1, "/w", method=METHOD_PROMPT) + guard.prompt_answered("inst", wire_id, {}) + assert guard.tracked_work_folders() == (["/w"], True) + guard.prompt_answered("inst", 1, {}) + assert guard.tracked_work_folders() == ([], True) + + +# ── The shared runtime (kiro, KAS) ── + + +def _runtime(folder: str): + rt, reader, proc = _make_runtime() + rt._process_instance = "inst-a" + _register(rt, SID) + rt._session_cwds[SID] = folder + return rt, reader, proc + + +@pytest.mark.asyncio +async def test_a_runtime_prompt_is_busy_in_its_folder_until_the_backend_answers(repo, tmp_path): + rt, reader, _proc = _runtime(str(repo / "sub")) + req_id = await rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []}) + assert await _busy(repo) is True + assert await _busy(tmp_path / "other") is False + # Nobody waits on it (the caller stopped reading); the backend's answer still + # ends the record when the reader sees it. + reader_task = asyncio.ensure_future(rt._reader_loop()) + try: + _feed(reader, {"jsonrpc": "2.0", "id": req_id, "result": {"stopReason": "end_turn"}}) + await async_wait_until(lambda: guard.tracked_work_folders() == ([], True)) + assert await _busy(repo) is False + finally: + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_a_runtime_prompt_whose_session_folder_is_unknown_is_busy_everywhere(tmp_path): + rt, _reader, _proc = _make_runtime() + rt._process_instance = "inst-a" + _register(rt, SID) + await rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []}) + assert guard.tracked_work_folders() == ([], False) + assert await _busy(tmp_path / "other") is True + + +@pytest.mark.asyncio +async def test_other_runtime_requests_are_not_recorded(repo): + rt, _reader, _proc = _runtime(str(repo)) + await rt.send_request("session/set_mode", {"sessionId": SID, "modeId": "x"}) + assert guard.tracked_work_folders() == ([], True) + + +@pytest.mark.asyncio +async def test_a_runtime_prompt_waits_out_a_checkout_of_its_folder_before_it_is_written(repo): + rt, _reader, proc = _runtime(str(repo / "sub")) + root = os.path.realpath(repo) + assert guard.reserve(root) + send = asyncio.ensure_future(rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []})) + for _ in range(20): + await asyncio.sleep(0) + assert not send.done() + proc.stdin.write.assert_not_called() + assert guard.tracked_work_folders() == ([], True) + guard.release(root) + await await_until(send) + proc.stdin.write.assert_called() + assert await _busy(repo) is True + + +@pytest.mark.asyncio +async def test_an_answer_from_a_respawned_process_does_not_end_the_old_prompt(repo): + rt, reader, _proc = _runtime(str(repo)) + req_id = await rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []}) + # The process was replaced without its tree confirmed gone; the new one + # reuses the request id. + rt._process_instance = "inst-b" + reader_task = asyncio.ensure_future(rt._reader_loop()) + try: + _feed(reader, {"jsonrpc": "2.0", "id": req_id, "result": {}}) + _feed(reader, {"jsonrpc": "2.0", "method": "noop", "params": {}}) + for _ in range(50): + await asyncio.sleep(0) + assert await _busy(repo) is True + finally: + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_a_confirmed_teardown_ends_the_runtime_prompts(repo, monkeypatch): + rt, _reader, proc = _runtime(str(repo)) + await rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []}) + # A teardown that left a survivor keeps the record... + rt._teardown_instance = "inst-a" + rt._teardown_survivors = {9999: object()} + monkeypatch.setattr( + "kiro_crew.acp.runtime._prune_dead_descendants", lambda children: list(children) + ) + await rt._recheck_teardown_survivors() + assert await _busy(repo) is True + # ...until a re-check confirms the whole tree gone. + monkeypatch.setattr("kiro_crew.acp.runtime._prune_dead_descendants", lambda children: []) + await rt._recheck_teardown_survivors() + assert rt.process_tree_confirmed_dead + assert await _busy(repo) is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("confirmed", [True, False]) +async def test_a_runtime_kill_ends_its_prompts_only_when_the_tree_is_confirmed_gone( + repo, monkeypatch, confirmed +): + from test_acp_runtime_death import _neuter_kill_side_effects + + rt, _reader, proc = _runtime(str(repo)) + _neuter_kill_side_effects(monkeypatch, proc) + await rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []}) + proc.returncode = 0 if confirmed else None + await rt.kill(expected=True) + assert rt.process_tree_confirmed_dead is confirmed + assert await _busy(repo) is (not confirmed) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["create", "load"]) +async def test_a_session_the_runtime_opens_is_placed_on_its_folder( + repo, tmp_path, monkeypatch, path +): + from kiro_crew.acp.types import METHOD_SESSION_LOAD, METHOD_SESSION_NEW + + rt, _reader, _proc = _make_runtime() + rt._process_instance = "inst-a" + rt._can_load_session = True + work = str(repo / "sub") + + async def _fake_send(method, params, timeout=None): + if method == METHOD_SESSION_NEW: + return {"sessionId": "sid-new", "modes": {"currentModeId": "kirocrew"}} + if method == METHOD_SESSION_LOAD: + return {"modes": {"currentModeId": "kirocrew"}, "models": []} + return {} + + monkeypatch.setattr(rt, "_send_and_await", _fake_send) + if path == "create": + handle = await rt.create_session(cwd=work, agent="kirocrew") + else: + handle = await rt.load_session( + str(tmp_path / "sid-123.json"), "sid-123", cwd=work, agent="kirocrew" + ) + await rt.send_request(METHOD_PROMPT, {"sessionId": handle._session_id, "prompt": []}) + folders, placed = guard.tracked_work_folders() + assert placed and folders and await _busy(repo) is True + assert await _busy(tmp_path / "other") is False + # A destroyed session's folder goes with it; its unanswered prompt does not. + rt.unregister_session(handle._session_id) + assert handle._session_id not in rt._session_cwds + assert await _busy(repo) is True + + +# ── The adapter client (claude, codex, goose, opencode, ...) ── + + +def _client(folder: str) -> tuple[AcpClient, MagicMock]: + client = AcpClient() + proc = MagicMock() + proc.stdin = MagicMock() + proc.stdin.write = MagicMock() + proc.stdin.drain = AsyncMock() + proc.stdout = asyncio.StreamReader() + proc.returncode = None + client._process = proc + client._process_instance = "cli-a" + client._spawn_work_dir = folder + return client, proc + + +def _line(obj: dict) -> bytes: + return (json.dumps(obj) + "\n").encode() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", sorted(guard.PROMPT_METHODS)) +async def test_a_client_prompt_is_busy_until_its_answer_is_read(repo, tmp_path, method): + client, proc = _client(str(repo / "sub")) + req_id = await client._send_request(method, {"sessionId": "s"}) + assert await _busy(repo) is True + assert await _busy(tmp_path / "other") is False + # Unrelated frames, including another request's answer, change nothing. + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "method": "session/update", "params": {}})) + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "id": req_id + 7, "result": {}})) + await client._read_message(timeout=1) + await client._read_message(timeout=1) + assert await _busy(repo) is True + # The answer is read whenever some loop reads it -- here long after any + # caller stopped waiting -- and only then does the folder go quiet. + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "id": req_id, "error": {"code": -1}})) + await client._read_message(timeout=1) + assert await _busy(repo) is False + + +@pytest.mark.asyncio +async def test_other_client_requests_are_not_recorded(repo): + client, _proc = _client(str(repo)) + await client._send_request("session/set_model", {"sessionId": "s", "modelId": "m"}) + assert guard.tracked_work_folders() == ([], True) + + +@pytest.mark.asyncio +async def test_a_client_prompt_waits_out_a_checkout_of_its_folder(repo): + client, proc = _client(str(repo)) + root = os.path.realpath(repo) + assert guard.reserve(root) + send = asyncio.ensure_future(client._send_request(METHOD_PROMPT, {"sessionId": "s"})) + for _ in range(20): + await asyncio.sleep(0) + assert not send.done() + proc.stdin.write.assert_not_called() + guard.release(root) + await await_until(send) + proc.stdin.write.assert_called() + assert await _busy(repo) is True + + +@pytest.mark.asyncio +async def test_a_client_prompt_with_no_folder_is_busy_everywhere(tmp_path): + client, _proc = _client("") + await client._send_request(METHOD_PROMPT, {"sessionId": "s"}) + assert await _busy(tmp_path / "other") is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("confirmed", [True, False]) +async def test_a_client_teardown_ends_its_prompts_only_when_the_tree_is_confirmed_gone( + repo, monkeypatch, confirmed +): + client, proc = _client(str(repo)) + await client._send_request(METHOD_PROMPT, {"sessionId": "s"}) + proc.returncode = 0 if confirmed else None + client._pid = None + client._child_pids = {} + client._reset_state() + assert client.process_tree_confirmed_dead is confirmed + assert await _busy(repo) is (not confirmed) + + +# ── Every writer, every reader ── + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "outcome, counted", + [("injected", False), ("startedNewTurn", True)], + ids=["injected", "new-turn"], +) +async def test_a_runtime_steering_request_is_counted_through_its_own_writer(repo, outcome, counted): + from kiro_crew.acp.types import METHOD_SESSION_STEERING + + rt, reader, proc = _runtime(str(repo)) + answer = await rt.send_request_for_answer( + METHOD_SESSION_STEERING, {"sessionId": SID, "prompt": []} + ) + assert await _busy(repo) is True + sent = json.loads(proc.stdin.write.call_args.args[0]) + reader_task = asyncio.ensure_future(rt._reader_loop()) + try: + _feed(reader, {"jsonrpc": "2.0", "id": sent["id"], "result": {"outcome": outcome}}) + await await_until(answer) + assert await _busy(repo) is counted + finally: + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_a_runtime_control_request_writer_counts_a_prompt_too(repo, monkeypatch): + rt, _reader, _proc = _runtime(str(repo)) + sent = asyncio.ensure_future( + rt._send_and_await(METHOD_PROMPT, {"sessionId": SID, "prompt": []}, timeout=60) + ) + await async_wait_until(lambda: guard.tracked_work_folders()[0] != []) + assert await _busy(repo) is True + sent.cancel() + await asyncio.gather(sent, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_a_runtime_answer_with_a_non_integer_id_ends_nothing(repo): + rt, reader, _proc = _runtime(str(repo)) + req_id = await rt.send_request(METHOD_PROMPT, {"sessionId": SID, "prompt": []}) + reader_task = asyncio.ensure_future(rt._reader_loop()) + try: + _feed(reader, {"jsonrpc": "2.0", "id": req_id + 0.9, "result": {}}) + _feed(reader, {"jsonrpc": "2.0", "id": str(req_id), "result": {}}) + _feed(reader, {"jsonrpc": "2.0", "method": "noop", "params": {}}) + for _ in range(50): + await asyncio.sleep(0) + assert await _busy(repo) is True + finally: + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_a_client_answer_with_a_non_integer_id_ends_nothing(repo): + client, proc = _client(str(repo)) + req_id = await client._send_request(METHOD_PROMPT, {"sessionId": "s"}) + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "id": req_id + 0.9, "result": {}})) + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "id": True, "result": {}})) + await client._read_message(timeout=1) + await client._read_message(timeout=1) + assert await _busy(repo) is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "outcome, counted", + [("promptRequired", False), ("startedNewTurn", True)], + ids=["idle", "new-turn"], +) +async def test_a_client_steer_that_started_a_turn_stays_counted(repo, outcome, counted): + from kiro_crew.acp.types import METHOD_SESSION_STEERING + + client, proc = _client(str(repo)) + req_id = await client._send_request(METHOD_SESSION_STEERING, {"sessionId": "s"}) + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "id": req_id, "result": {"outcome": outcome}})) + await client._read_message(timeout=1) + assert await _busy(repo) is counted + + +@pytest.mark.asyncio +async def test_a_duplicate_answer_never_releases_a_turn_the_runtime_adapter_started(repo): + from kiro_crew.acp.types import METHOD_SESSION_STEERING + + rt, reader, proc = _runtime(str(repo)) + await rt.send_request_for_answer(METHOD_SESSION_STEERING, {"sessionId": SID, "prompt": []}) + sent = json.loads(proc.stdin.write.call_args.args[0]) + reader_task = asyncio.ensure_future(rt._reader_loop()) + try: + for result in ({"outcome": "startedNewTurn"}, {"outcome": "injected"}, {}): + _feed(reader, {"jsonrpc": "2.0", "id": sent["id"], "result": result}) + _feed(reader, {"jsonrpc": "2.0", "id": sent["id"], "error": {"code": -1}}) + _feed(reader, {"jsonrpc": "2.0", "method": "noop", "params": {}}) + for _ in range(50): + await asyncio.sleep(0) + assert await _busy(repo) is True + finally: + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + # Only the process tree's confirmed exit ends it. + guard.prompt_process_gone("inst-a") + assert await _busy(repo) is False + + +@pytest.mark.asyncio +async def test_a_duplicate_answer_never_releases_a_turn_the_client_adapter_started(repo): + from kiro_crew.acp.types import METHOD_SESSION_STEERING + + client, proc = _client(str(repo)) + req_id = await client._send_request(METHOD_SESSION_STEERING, {"sessionId": "s"}) + for result in ({"outcome": "startedNewTurn"}, {"outcome": "injected"}): + proc.stdout.feed_data(_line({"jsonrpc": "2.0", "id": req_id, "result": result})) + await client._read_message(timeout=1) + assert await _busy(repo) is True + + +def test_every_checkpoint_verb_is_tracked(): + from kiro_crew.acp.types import METHOD_KAS_CHECKPOINT_REVERT + + assert guard.is_tracked(METHOD_KAS_CHECKPOINT_REVERT) + assert guard.is_tracked("_kiro/checkpoint/revert") + for method in ("session/set_mode", "_kiro/session/delete", "_kiro/mcp/resetServer"): + assert not guard.is_tracked(method) + + +@pytest.mark.asyncio +async def test_a_checkpoint_restore_counts_until_its_answer_whoever_stopped_waiting(repo): + from kiro_crew.acp.types import METHOD_KAS_CHECKPOINT_REVERT + + rt, reader, _proc = _runtime(str(repo / "sub")) + root = os.path.realpath(repo) + # It waits out a switch of its folder before it is written... + assert guard.reserve(root) + send = asyncio.ensure_future( + rt.send_request(METHOD_KAS_CHECKPOINT_REVERT, {"sessionId": SID, "messageId": "m"}) + ) + for _ in range(20): + await asyncio.sleep(0) + assert not send.done() + guard.release(root) + req_id = await await_until(send) + # ...and counts, though its caller gave up, until the backend answers it. + assert await _busy(repo) is True + reader_task = asyncio.ensure_future(rt._reader_loop()) + try: + _feed(reader, {"jsonrpc": "2.0", "id": req_id, "result": {"success": True}}) + await async_wait_until(lambda: guard.tracked_work_folders() == ([], True)) + finally: + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + + +# ── The knowledge pool's legacy stream-json worker ── + + +def _cc_worker(folder: str): + from kiro_crew.knowledge.llm_pool import CCWorker + + worker = CCWorker(sandbox_mode="off") + proc = MagicMock() + proc.returncode = None + proc.stdin = MagicMock() + proc.stdin.write = MagicMock() + worker._proc = proc + worker._instance = "cc-a" + worker._spawn_cwd = folder + worker.shutdown = AsyncMock() + return worker, proc + + +@pytest.mark.asyncio +async def test_a_cc_worker_prompt_is_counted_until_its_result_is_read(repo): + worker, proc = _cc_worker(str(repo)) + seen: list[bool] = [] + + async def _drain(): + seen.append(await _busy(repo)) + await worker._event_queue.put({"type": "result", "result": "ok"}) + + proc.stdin.drain = _drain + assert await worker.send_message("hi", timeout=60) == "ok" + assert seen == [True] + assert await _busy(repo) is False + + +@pytest.mark.asyncio +async def test_a_cc_worker_reply_that_never_completes_stays_counted(repo): + worker, proc = _cc_worker(str(repo)) + proc.stdin.drain = AsyncMock() + with pytest.raises(asyncio.TimeoutError): + await worker.send_message("hi", timeout=0.05) # flake-ok: the timeout is the case + worker.shutdown.assert_awaited() + assert await _busy(repo) is True + + +# ── A prompt whose frame never reached the pipe ── + + +async def _send_on(rt, writer: str): + params = {"sessionId": SID, "prompt": []} + if writer == "send_request": + return await rt.send_request(METHOD_PROMPT, params) + if writer == "send_request_for_answer": + return await rt.send_request_for_answer(METHOD_PROMPT, params) + return await rt._send_and_await(METHOD_PROMPT, params, timeout=60) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("writer", ["send_request", "send_request_for_answer", "_send_and_await"]) +async def test_a_runtime_prompt_cancelled_before_its_write_is_not_counted(repo, writer): + """Cancelled while it waits for the shared write lock, the frame is never + written, so no backend can answer it and nothing may stay counted.""" + rt, _reader, proc = _runtime(str(repo)) + lock = rt._stdin_write_lock() + await lock.acquire() + try: + send = asyncio.ensure_future(_send_on(rt, writer)) + await async_wait_until(lambda: guard.tracked_work_folders()[0] != []) + assert await _busy(repo) is True + send.cancel() + await asyncio.gather(send, return_exceptions=True) + finally: + lock.release() + proc.stdin.write.assert_not_called() + assert guard.tracked_work_folders() == ([], True) + assert await _busy(repo) is False + + +@pytest.mark.asyncio +async def test_a_runtime_prompt_refused_by_a_dead_runtime_at_the_lock_is_not_counted(repo): + """A sibling's stall marks the runtime dead while this prompt waits for the + lock: the write is refused before a byte, so the record goes too.""" + from kiro_crew.acp.session_handle import AcpRuntimeDead + + rt, _reader, proc = _runtime(str(repo)) + lock = rt._stdin_write_lock() + await lock.acquire() + send = asyncio.ensure_future(_send_on(rt, "send_request")) + await async_wait_until(lambda: guard.tracked_work_folders()[0] != []) + rt._dead = True + lock.release() + with pytest.raises(AcpRuntimeDead): + await send + proc.stdin.write.assert_not_called() + assert guard.tracked_work_folders() == ([], True) + + +@pytest.mark.asyncio +async def test_a_runtime_prompt_cancelled_after_its_write_stays_counted(repo): + """Once the frame is on the pipe the backend may run it, so a caller that + gives up during the drain leaves it counted until the answer comes.""" + rt, _reader, proc = _runtime(str(repo)) + gate = asyncio.Event() + proc.stdin.drain = AsyncMock(side_effect=gate.wait) + send = asyncio.ensure_future(_send_on(rt, "send_request")) + await async_wait_until(lambda: proc.stdin.write.called) + send.cancel() + await asyncio.gather(send, return_exceptions=True) + assert await _busy(repo) is True + + +@pytest.mark.asyncio +async def test_a_client_prompt_cancelled_before_its_write_is_not_counted(repo): + client, proc = _client(str(repo)) + lock = client._stdin_write_lock() + await lock.acquire() + try: + send = asyncio.ensure_future(client._send_request(METHOD_PROMPT, {"sessionId": "s"})) + await async_wait_until(lambda: guard.tracked_work_folders()[0] != []) + assert await _busy(repo) is True + send.cancel() + await asyncio.gather(send, return_exceptions=True) + finally: + lock.release() + proc.stdin.write.assert_not_called() + assert await _busy(repo) is False + + +@pytest.mark.asyncio +async def test_a_client_prompt_cancelled_after_its_write_stays_counted(repo): + client, proc = _client(str(repo)) + gate = asyncio.Event() + proc.stdin.drain = AsyncMock(side_effect=gate.wait) + send = asyncio.ensure_future(client._send_request(METHOD_PROMPT, {"sessionId": "s"})) + await async_wait_until(lambda: proc.stdin.write.called) + send.cancel() + await asyncio.gather(send, return_exceptions=True) + assert await _busy(repo) is True + + +@pytest.mark.asyncio +async def test_a_cc_worker_prompt_that_never_reached_the_pipe_is_not_counted(repo): + """A zero timeout cancels the delivery before its first step: nothing was + written, so the folder is free again once the worker retires.""" + worker, proc = _cc_worker(str(repo)) + proc.stdin.drain = AsyncMock() + with pytest.raises(asyncio.TimeoutError): + await worker.send_message("hi", timeout=0) + proc.stdin.write.assert_not_called() + worker.shutdown.assert_awaited() + assert await _busy(repo) is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["runtime", "client"]) +async def test_a_prompt_whose_lock_wait_stalled_is_not_counted(repo, monkeypatch, transport): + """A lock-wait stall wrote no byte of the frame.""" + from kiro_crew.acp import client_transport, runtime_wire + from kiro_crew.acp.transport_framing import RequestWriteResult + + async def _lock_stall(*_args, **_kwargs): + return RequestWriteResult.LOCK_STALL + + if transport == "runtime": + rt, _reader, _proc = _runtime(str(repo)) + monkeypatch.setattr(runtime_wire, "write_request_frame_bounded", _lock_stall) + send = _send_on(rt, "send_request") + else: + client, _proc = _client(str(repo)) + monkeypatch.setattr(client_transport, "write_request_frame_bounded", _lock_stall) + send = client._send_request(METHOD_PROMPT, {"sessionId": "s"}) + with pytest.raises(Exception): + await send + assert guard.tracked_work_folders() == ([], True) diff --git a/test/test_remote_chat_file_guard.py b/test/test_remote_chat_file_guard.py index 5ed17de97ee..8193b63de51 100644 --- a/test/test_remote_chat_file_guard.py +++ b/test/test_remote_chat_file_guard.py @@ -19,7 +19,7 @@ from kiro_crew.dashboard import chat_utils, handlers from kiro_crew.dashboard import remote_chat_guard as guard -from kiro_crew.dashboard.handlers import source_providers +from kiro_crew.dashboard.handlers import git_branches, source_providers from kiro_crew.dashboard.routes import taskrunner pytestmark = pytest.mark.asyncio @@ -35,6 +35,8 @@ "/api/project/git": ("api_project_git", "GET"), "/api/project/git/status": ("api_project_git_status", "GET"), "/api/project/git/log": ("api_project_git_log", "GET"), + "/api/project/git/branches": ("api_project_git_branches", "GET"), + "/api/project/git/switch": ("api_project_git_switch", "POST"), } SECRET_PATH = "/srv/owner-private/notes/plan.md" REMOTE, LOCAL = "chat-remote", "chat-local" @@ -54,7 +56,9 @@ async def handler(request: web.Request) -> web.Response: return handler for name, _method in GUARDED.values(): - monkeypatch.setattr(handlers, name, stand_in(name)) + # The branch switcher's routes live in their own handler module. + owner = git_branches if hasattr(git_branches, name) else handlers + monkeypatch.setattr(owner, name, stand_in(name)) app = web.Application() taskrunner.register(app) slots = {REMOTE: SimpleNamespace(key=REMOTE), LOCAL: SimpleNamespace(key=LOCAL)} diff --git a/test/test_repo_checkout_guard.py b/test/test_repo_checkout_guard.py new file mode 100644 index 00000000000..d791ee6fb97 --- /dev/null +++ b/test/test_repo_checkout_guard.py @@ -0,0 +1,1217 @@ +"""A branch switch's reservation orders it against every turn and sub-agent run in the repository.""" + +from __future__ import annotations + +import asyncio +import os +import sys +import threading +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from chat_test_helpers import _make_state + +from kiro_crew import repo_checkout_guard as guard +from kiro_crew.dashboard import chat_runner +from kiro_crew.dashboard import repo_checkout_guard as readers +from kiro_crew.dashboard.handlers import git_branches as gb +from kiro_crew.dashboard.state import _ChatSlot +from kiro_crew.subagent import SubagentInfo, SubagentManager +from kiro_crew.testing.wait import async_wait_until, await_until, default_timeout + + +class _SeenEvent(asyncio.Event): + """A reservation event that says when something starts waiting on it.""" + + def __init__(self) -> None: + super().__init__() + self.waiting = asyncio.Event() + + async def wait(self) -> bool: + self.waiting.set() + return await super().wait() + + +@pytest.fixture(autouse=True) +def _clean_registry(): + guard._reserved.clear() + guard._tracked.clear() + yield + guard._reserved.clear() + guard._tracked.clear() + + +@pytest.fixture +def repo(tmp_path): + root = tmp_path / "repo" + (root / "sub").mkdir(parents=True) + (tmp_path / "other").mkdir() + return root + + +def _reserve_seen(root: str) -> _SeenEvent: + assert guard.reserve(root) + event = _SeenEvent() + guard._reserved[root] = event + return event + + +def test_one_reservation_per_root(repo): + root = os.path.realpath(repo) + assert guard.reserve(root) is True + assert guard.reserve(root) is False + assert guard.is_reserved(root) + guard.release(root) + assert not guard.is_reserved(root) + guard.release(root) # releasing twice is harmless + + +@pytest.mark.asyncio +async def test_no_reservation_returns_without_touching_the_path(): + await guard.wait_for_checkout(None) + await guard.wait_for_checkout("/no/such/project") + await readers.wait_for_subagent_checkout(None, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("where", ["root", "sub", "parent"]) +async def test_a_project_in_or_around_the_repo_waits_for_the_release(repo, where): + root = os.path.realpath(repo) + project = {"root": repo, "sub": repo / "sub", "parent": repo.parent}[where] + event = _reserve_seen(root) + waiter = asyncio.create_task(guard.wait_for_checkout(str(project))) + await await_until(event.waiting) + assert not waiter.done() + guard.release(root) + await await_until(waiter) + + +@pytest.mark.asyncio +async def test_an_unrelated_project_does_not_wait(repo): + guard.reserve(os.path.realpath(repo)) + await await_until(asyncio.ensure_future(guard.wait_for_checkout(str(repo.parent / "other")))) + + +@pytest.mark.asyncio +async def test_a_long_checkout_is_waited_out_not_abandoned(repo, monkeypatch, caplog): + """No deadline: a checkout that outlasts the log interval still holds the work back.""" + monkeypatch.setattr(guard, "WAIT_LOG_SECS", 0.01) + root = os.path.realpath(repo) + guard.reserve(root) + waiter = asyncio.create_task(guard.wait_for_checkout(str(repo), waiter="session chat-9")) + + await async_wait_until( + lambda: "session chat-9 still waiting" in caplog.text, + describe=lambda: caplog.text, + ) + assert not waiter.done() + guard.release(root) + await await_until(waiter) + + +@pytest.mark.asyncio +async def test_a_reservation_dropped_mid_lookup_is_rechecked(repo, monkeypatch): + root = os.path.realpath(repo) + guard.reserve(root) + real = guard._covering_root + + def drop_then_lookup(project, roots): + found = real(project, roots) + guard._reserved.pop(root, None) + return found + + monkeypatch.setattr(guard, "_covering_root", drop_then_lookup) + await await_until(asyncio.ensure_future(guard.wait_for_checkout(str(repo)))) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["result", "error", "cancelled"]) +async def test_release_when_done_follows_the_worker_not_its_awaiter(repo, outcome): + root = os.path.realpath(repo) + guard.reserve(root) + worker: asyncio.Future = asyncio.get_running_loop().create_future() + guard.release_when_done(root, worker) + assert guard.is_reserved(root) + if outcome == "result": + worker.set_result(({}, 200)) + elif outcome == "error": + worker.set_exception(OSError("git died")) + else: + worker.cancel() + await asyncio.sleep(0) + assert not guard.is_reserved(root) + + +def test_covering_root_skips_roots_on_another_drive(monkeypatch, repo): + def other_drive(paths): + raise ValueError("different drives") + + monkeypatch.setattr(guard.os.path, "commonpath", other_drive) + assert guard._covering_root(str(repo), [os.path.realpath(repo)]) is None + + +def _parent_state(project, key="chat-1"): + slot = _ChatSlot(key) + slot.project = str(project) + return SimpleNamespace(_slots={key: slot}), slot + + +@pytest.mark.asyncio +@pytest.mark.parametrize("via", ["cwd", "parent_project"]) +async def test_a_subagent_run_in_the_repo_waits(repo, tmp_path, via): + from kiro_crew.dashboard.chat_utils import effective_session_key + + state, slot = _parent_state(repo if via == "parent_project" else tmp_path / "other") + info = SimpleNamespace( + parent_session_key=effective_session_key(slot), + cwd=str(repo / "sub") if via == "cwd" else str(tmp_path / "own"), + ) + root = os.path.realpath(repo) + event = _reserve_seen(root) + waiter = asyncio.create_task(readers.wait_for_subagent_checkout(state, info)) + await await_until(event.waiting) + assert not waiter.done() + guard.release(root) + await await_until(waiter) + + +@pytest.mark.asyncio +async def test_a_subagent_of_another_session_does_not_wait(repo, tmp_path): + state, _slot = _parent_state(repo) + broken = MagicMock() + broken.project = str(repo) + state._slots["broken"] = broken # cannot be keyed: skipped, not fatal + guard.reserve(os.path.realpath(repo)) + info = SimpleNamespace(parent_session_key="dashboard:someone-else", cwd=str(tmp_path / "own")) + await await_until(asyncio.ensure_future(readers.wait_for_subagent_checkout(state, info))) + + +@pytest.mark.asyncio +async def test_dashboard_state_installs_the_subagent_gate(tmp_path, repo): + manager = SimpleNamespace() + state = _make_state(tmp_path, subagents=manager) + assert state.subagents is manager + assert asyncio.iscoroutinefunction(manager.pre_run_gate) + root = os.path.realpath(repo) + event = _reserve_seen(root) + info = SimpleNamespace(parent_session_key="", cwd=str(repo)) + run = asyncio.create_task(manager.pre_run_gate(info)) + await await_until(event.waiting) + assert not run.done() + guard.release(root) + await await_until(run) + + +@pytest.mark.asyncio +async def test_a_subagent_run_executes_only_after_the_gate(tmp_path): + order: list[str] = [] + release = asyncio.Event() + gated = asyncio.Event() + manager = SubagentManager(sessions=MagicMock(), ctx_builder=MagicMock()) + + async def gate(info): + order.append("gate") + gated.set() + await release.wait() + + async def run_inner(info, session_key): + order.append("run") + info.done = True + + manager.pre_run_gate = gate + manager._run_inner = run_inner + info = SubagentInfo(id="sa-1", task="edit a file") + run = asyncio.create_task(manager._run(info)) + await await_until(gated) + assert order == ["gate"] and not run.done() + release.set() + await await_until(run) + assert order == ["gate", "run"] + + +@pytest.mark.asyncio +async def test_a_stop_during_the_gate_ends_the_run_as_stopped(tmp_path): + gated = asyncio.Event() + manager = SubagentManager(sessions=MagicMock(), ctx_builder=MagicMock()) + + async def gate(info): + gated.set() + await asyncio.Event().wait() + + manager.pre_run_gate = gate + manager._run_inner = AsyncMock() + info = SubagentInfo(id="sa-3", task="edit a file") + info.user_stopped = True + run = asyncio.create_task(manager._run(info)) + await await_until(gated) + run.cancel() + await await_until(asyncio.gather(run, return_exceptions=True)) + manager._run_inner.assert_not_awaited() + assert info.done + + +def _run(run_id, *, cwd="", parent="", **extra): + return SimpleNamespace( + id=run_id, conversation_key="", cwd=cwd, parent_session_key=parent, **extra + ) + + +def test_subagent_folders_walk_the_lineage_to_the_root_chat(repo, tmp_path): + from kiro_crew.dashboard.chat_utils import effective_session_key + + state, slot = _parent_state(repo) + started = str(tmp_path / "started") + child = _run("c1", cwd=str(tmp_path / "other"), parent=effective_session_key(slot)) + # No override: the folder its session started in is its own. + grandchild = _run("g1", parent="subagent:c1", _session_cwd=started) + state.subagents = SimpleNamespace(all_agents=[child, grandchild]) + assert readers.subagent_folders(state, grandchild) == [ + started, + str(tmp_path / "other"), + str(repo), + ] + looped = _run("loop", parent="subagent:loop", _session_cwd=started) + state.subagents = SimpleNamespace(all_agents=[looped]) + assert readers.subagent_folders(state, looped) == [started] + + +@pytest.mark.parametrize("selectors", [{}, {"crew": "reviewer"}, {"agent": "builder"}]) +def test_a_run_not_placed_yet_has_an_unknown_lineage(tmp_path, selectors): + """No override and no session yet: where it starts is the allocator's choice. + + A member's workspace, a resumed conversation's stored folder, the pool's or a + per-session work folder; a parent chat's project does not decide it either. + """ + state, slot = _parent_state(tmp_path / "outside") + from kiro_crew.dashboard.chat_utils import effective_session_key + + run = _run("r1", parent=effective_session_key(slot), **selectors) + state.subagents = SimpleNamespace(all_agents=[run]) + with pytest.raises(readers.LineageUnknown): + readers.subagent_folders(state, run) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("started", [False, True]) +async def test_busy_check_sees_a_parentless_member_run_in_the_repo(repo, started): + """The reported case: no parent, no cwd, placed in the member's workspace.""" + run = _run("m1", crew="reviewer", _session_cwd=str(repo) if started else "") + + class _Subagents: + running = [run] + all_agents = [run] + + async def has_pending_work_for_async(self, key): + return False + + state = SimpleNamespace(_slots={}, subagents=_Subagents()) + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is True + + +@pytest.mark.asyncio +async def test_a_run_not_placed_yet_waits_out_any_checkout(repo): + state = SimpleNamespace(_slots={}, subagents=SimpleNamespace(all_agents=[])) + root = os.path.realpath(repo) + event = _reserve_seen(root) + waiter = asyncio.create_task(readers.wait_for_subagent_checkout(state, _run("m1", crew="x"))) + await await_until(event.waiting) + assert not waiter.done() + guard.release(root) + await await_until(waiter) + + +@pytest.mark.asyncio +async def test_a_continuation_reaches_the_gate_with_no_stale_session_folder(tmp_path): + """The previous execution's folder must not stand in for this one's placement.""" + seen: list[str] = [] + manager = SubagentManager(sessions=MagicMock(), ctx_builder=MagicMock()) + + async def gate(info): + seen.append(getattr(info, "_session_cwd", None)) + + manager.pre_run_gate = gate + manager._run_inner = AsyncMock() + info = SubagentInfo(id="sa-4", task="follow up") + setattr(info, "_session_cwd", str(tmp_path / "previous")) + await await_until(asyncio.ensure_future(manager._run(info))) + assert seen == [""] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("where", ["own_cwd", "ancestor_cwd", "elsewhere"]) +async def test_busy_check_sees_a_running_subagent_working_in_the_repo(repo, tmp_path, where): + """A run started from a chat outside the repository, working inside it.""" + outside = tmp_path / "other" + parent = _run( + "p1", cwd=str(repo) if where == "ancestor_cwd" else str(outside), parent="dashboard:x" + ) + child = _run( + "c1", cwd=str(repo / "sub") if where == "own_cwd" else str(outside), parent="subagent:p1" + ) + + class _Subagents: + running = [child] + all_agents = [parent, child] + + async def has_pending_work_for_async(self, key): + return False + + slot = _ChatSlot("chat-x") + slot.project = str(outside) + state = SimpleNamespace(_slots={slot.key: slot}, subagents=_Subagents()) + busy = await gb._repo_has_running_work(state, os.path.realpath(repo)) + assert busy is (where != "elsewhere") + + +@pytest.mark.asyncio +async def test_busy_check_fails_closed_on_an_unreadable_registry(repo): + class _Broken: + @property + def running(self): + raise RuntimeError("registry unavailable") + + state = SimpleNamespace(_slots={}, subagents=_Broken()) + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is True + + +@pytest.mark.asyncio +async def test_a_non_coroutine_gate_is_ignored(tmp_path): + manager = SubagentManager(sessions=MagicMock(), ctx_builder=MagicMock()) + manager.pre_run_gate = MagicMock() + manager._run_inner = AsyncMock() + info = SubagentInfo(id="sa-2", task="t") + await await_until(asyncio.ensure_future(manager._run(info))) + manager.pre_run_gate.assert_not_called() + manager._run_inner.assert_awaited_once() + + +class _Stop(Exception): + pass + + +def _stop_at_setup(monkeypatch) -> list[str]: + reached: list[str] = [] + + async def setup(*args, **kwargs): + reached.append("setup") + raise _Stop + + monkeypatch.setattr(chat_runner, "_refresh_genuine_turn_allowances", setup) + return reached + + +@pytest.mark.asyncio +async def test_a_turn_does_no_work_until_the_checkout_is_released(tmp_path, repo, monkeypatch): + """The wait sits at ``_run_chat``'s entry, before the first await of its setup.""" + reached = _stop_at_setup(monkeypatch) + state = _make_state(tmp_path) + slot = _ChatSlot("chat-1") + slot.project = str(repo / "sub") + state._slots[slot.key] = slot + root = os.path.realpath(repo) + event = _reserve_seen(root) + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, "edit a file")) + await await_until(event.waiting) + assert reached == [] and not turn.done() + + guard.release(root) + with pytest.raises(_Stop): + await await_until(turn) + assert reached == ["setup"] + + +@pytest.mark.asyncio +async def test_a_turn_records_the_project_it_starts_in(tmp_path, repo, monkeypatch): + """Recorded before the wait, so a busy check during it already sees the turn.""" + _stop_at_setup(monkeypatch) + state = _make_state(tmp_path) + slot = _ChatSlot("chat-1") + slot.project = str(repo / "sub") + slot._checkout_turn_unplaced = True # left over from an earlier turn + state._slots[slot.key] = slot + root = os.path.realpath(repo) + event = _reserve_seen(root) + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, "edit a file")) + await await_until(event.waiting) + assert slot._checkout_turn_projects == (str(repo / "sub"),) + assert slot._checkout_turn_unplaced is False + guard.release(root) + with pytest.raises(_Stop): + await await_until(turn) + + +def _running_slot(project: str, *, turn_projects=(), unplaced=False, running=True) -> _ChatSlot: + slot = _ChatSlot("chat-1") + slot.project = project + slot._checkout_turn_projects = tuple(turn_projects) + slot._checkout_turn_unplaced = unplaced + slot._turn_admission_reserved = running # what `running` reads while a turn is admitted + return slot + + +def _sessions_with(cwd: str | None = None, *, error: Exception | None = None): + def get_provider(_key): + if error is not None: + raise error + return None if cwd is None else SimpleNamespace(cwd=cwd) + + return SimpleNamespace(get_provider=get_provider) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("running", [True, False]) +async def test_busy_check_sees_a_turn_whose_project_was_changed_mid_turn(repo, tmp_path, running): + """A project picked mid-turn moves ``project`` now, the session only next turn.""" + slot = _running_slot( + str(tmp_path / "picked-mid-turn"), turn_projects=[str(repo)], running=running + ) + state = SimpleNamespace(_slots={slot.key: slot}, subagents=None) + root = os.path.realpath(repo) + # An idle slot's record is the last turn's, which the busy check ignores. + assert await gb._repo_has_running_work(state, root) is running + assert (slot in gb._slots_in_repo(state, root)) is running + + +@pytest.mark.asyncio +@pytest.mark.parametrize("running", [True, False]) +async def test_busy_check_reads_the_folder_the_live_session_runs_in(repo, tmp_path, running): + """``set_project``, a member rebinding or a reset left armed: ``project`` and + the turn's records all name elsewhere, but the live session runs in the repo.""" + elsewhere = str(tmp_path / "other") + slot = _running_slot(elsewhere, turn_projects=[elsewhere], running=running) + state = SimpleNamespace( + _slots={slot.key: slot}, subagents=None, sessions=_sessions_with(str(repo / "sub")) + ) + root = os.path.realpath(repo) + assert await gb._repo_has_running_work(state, root) is running + + +@pytest.mark.asyncio +@pytest.mark.parametrize("running", [True, False]) +async def test_an_unplaced_turn_is_busy_in_every_repository(repo, tmp_path, running): + """Its session could land anywhere, so no repository can be switched under it.""" + slot = _running_slot(str(tmp_path / "other"), unplaced=True, running=running) + state = SimpleNamespace(_slots={slot.key: slot}, subagents=None) + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is running + + +@pytest.mark.asyncio +async def test_an_unreadable_session_registry_counts_as_busy(repo, tmp_path): + slot = _running_slot(str(tmp_path / "other")) + state = SimpleNamespace( + _slots={slot.key: slot}, subagents=None, sessions=_sessions_with(error=RuntimeError()) + ) + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is True + # An idle slot does not ask the registry at all. + slot._turn_admission_reserved = False + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is False + + +def test_a_running_root_chat_lends_its_turn_folders_to_the_lineage(repo, tmp_path): + from kiro_crew.dashboard.chat_utils import effective_session_key + + slot = _running_slot( + str(tmp_path / "picked-mid-turn"), turn_projects=[str(repo)], unplaced=True + ) + child = _run("c1", cwd=str(tmp_path / "own"), parent=effective_session_key(slot)) + state = SimpleNamespace( + _slots={slot.key: slot}, + subagents=SimpleNamespace(all_agents=[child]), + sessions=_sessions_with(str(repo / "sub")), + ) + folders = readers.subagent_folders(state, child) + assert str(repo) in folders and str(repo / "sub") in folders + + +def _turn_state(tmp_path, monkeypatch): + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + state = _make_state(tmp_path) + state.broadcast_ws = MagicMock() + state.push_slots_update = MagicMock() + state.push_refresh = MagicMock() + state.context_builder = None + state.consolidator = None + state._hook_store = None + state._yolo = False + state.sessions.get_pid = MagicMock(return_value=None) + state.sessions.release = MagicMock() + state.sessions.reset = AsyncMock() + state.sessions.get_slack_link = MagicMock(return_value=(None, None)) + return state + + +async def _finish(turn: asyncio.Task) -> None: + turn.cancel() + await await_until(asyncio.gather(turn, return_exceptions=True)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/prompts", "/prompts get review"]) +async def test_prompt_commands_read_the_project_the_entry_wait_covered( + tmp_path, repo, monkeypatch, command +): + """A project picked during the entry wait may name a repository mid-switch, + so the pre-claim prompt reads stay on the project the wait covered.""" + state = _turn_state(tmp_path, monkeypatch) + read_from: list[object] = [] + + def list_prompts(project_dir): + read_from.append(project_dir) + return [] + + def resolve(message, project): + read_from.append(project) + return message, "not_found", "" + + monkeypatch.setattr(chat_runner, "_list_aim_prompts", list_prompts) + monkeypatch.setattr(chat_runner, "_resolve_prompt_mention", resolve) + slot = state.get_or_create_slot("s1") + slot._titled = True + slot.project = str(repo / "sub") + root = os.path.realpath(repo) + event = _reserve_seen(root) + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, command)) + await await_until(event.waiting) + slot.project = str(tmp_path / "other") # picked while the turn waited + guard.release(root) + await async_wait_until(lambda: read_from) + assert [str(p) for p in read_from] == [str(repo / "sub")] + await _finish(turn) + + +@pytest.mark.asyncio +async def test_a_turn_allocating_its_session_waits_out_a_checkout_anywhere( + tmp_path, repo, monkeypatch +): + """Before the claim the session's folder is unknown, so a checkout of a + repository the slot's project is not in still holds the claim back.""" + state = _turn_state(tmp_path, monkeypatch) + claimed: list[bool] = [] + + async def get_or_create(*_args, **_kwargs): + claimed.append(slot._checkout_turn_unplaced) + raise _Stop + + state.sessions.get_or_create = get_or_create + slot = state.get_or_create_slot("s1") + slot._titled = True + slot.project = str(tmp_path / "other") + slot._turn_admission_reserved = True + root = os.path.realpath(repo) + event = _reserve_seen(root) + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, "edit a file")) + await await_until(event.waiting) + assert claimed == [] and slot._checkout_turn_unplaced is True + # Any other switch arriving now refuses: this turn could land in its repo. + assert await gb._repo_has_running_work(state, os.path.realpath(tmp_path / "unrelated")) is True + guard.release(root) + await async_wait_until(lambda: claimed == [True]) + await _finish(turn) + + +@pytest.mark.asyncio +async def test_a_claimed_session_records_its_folder_and_waits_out_its_checkout( + tmp_path, repo, monkeypatch +): + """A session that runs where ``project`` does not point (a deferred reset left + armed, a resumed conversation's stored folder) is recorded by its own report.""" + state = _turn_state(tmp_path, monkeypatch) + client = MagicMock() + client.cwd = str(repo / "sub") + root = os.path.realpath(repo) + seen: dict[str, _SeenEvent] = {} + + async def get_or_create(*_args, **_kwargs): + # A switch of the repo starts while the session is being claimed. + seen["event"] = _reserve_seen(root) + return client, False, False + + state.sessions.get_or_create = get_or_create + slot = state.get_or_create_slot("s1") + slot._titled = True + slot.project = str(tmp_path / "other") + slot._turn_admission_reserved = True + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, "edit a file")) + await async_wait_until(lambda: "event" in seen) + await await_until(seen["event"].waiting) + assert str(repo / "sub") in slot._checkout_turn_projects + assert slot._checkout_turn_unplaced is False + assert await gb._repo_has_running_work(state, root) is True + guard.release(root) + await _finish(turn) + + +@pytest.mark.asyncio +async def test_a_session_that_reports_no_folder_leaves_the_turn_unplaced( + tmp_path, repo, monkeypatch +): + """A provider that never says where it runs keeps switches refused for the turn.""" + state = _turn_state(tmp_path, monkeypatch) + client = MagicMock() + client.cwd = "" + state.sessions.get_or_create = AsyncMock(return_value=(client, False, False)) + slot = state.get_or_create_slot("s1") + slot._titled = True + other = str(tmp_path / "other") + slot.project = other + slot._turn_admission_reserved = True + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, "edit a file")) + # The claim's record landed (entry, allocation, the session's empty report). + await async_wait_until(lambda: len(slot._checkout_turn_projects) == 3) + assert slot._checkout_turn_projects == (other, other, "") + assert slot._checkout_turn_unplaced is True + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is True + await _finish(turn) + + +@pytest.mark.asyncio +async def test_a_stop_and_a_new_row_during_the_wait_stay_visible_to_the_turn( + tmp_path, repo, monkeypatch +): + """The wait follows the Stop snapshots and the replay-row freeze. + + A Stop pressed while the turn waits must still read as pressed when it + reaches dispatch, and a row appended meanwhile must not become its trigger. + """ + seen: dict[str, object] = {} + + async def setup(*args, **kwargs): + # The turn's phases share its state on its ``_ChatTurn``, which every + # phase frame holds as ``turn``. + frame = sys._getframe(1) + while frame is not None and not hasattr(frame.f_locals.get("turn"), "_stop_gen_at_entry"): + frame = frame.f_back + assert frame is not None + seen["stop_gen"] = frame.f_locals["turn"]._stop_gen_at_entry + seen["replay"] = frame.f_locals["turn"]._current_replay_message + raise _Stop + + monkeypatch.setattr(chat_runner, "_refresh_genuine_turn_allowances", setup) + state = _make_state(tmp_path) + slot = _ChatSlot("chat-1") + slot.project = str(repo) + slot.messages.append({"role": "user", "content": "edit a file"}) + trigger = slot.messages[-1] + state._slots[slot.key] = slot + root = os.path.realpath(repo) + event = _reserve_seen(root) + gen_before = slot._stop_generation + + turn = asyncio.create_task(chat_runner._run_chat(state, slot, "edit a file")) + await await_until(event.waiting) + slot._stop_generation += 1 + slot.messages.append({"role": "user", "content": "edit a file"}) + + guard.release(root) + with pytest.raises(_Stop): + await await_until(turn) + assert seen["stop_gen"] == gen_before + assert seen["replay"] is trigger + + +@pytest.fixture +def switch_boundary(monkeypatch, repo): + root = os.path.realpath(repo) + monkeypatch.setattr(gb, "_resolve_repo_root", AsyncMock(return_value=root)) + monkeypatch.setattr(gb, "sel", MagicMock()) + monkeypatch.setattr(gb, "deny_non_dashboard_caller", lambda *args: None) + monkeypatch.setattr(gb, "require_owner_dashboard_request", AsyncMock(return_value=None)) + body = {"path": root, "branch": "feature"} + monkeypatch.setattr(gb, "read_bounded_json", AsyncMock(return_value=(body, None))) + return root + + +def _switch_request(state): + return SimpleNamespace(app={"state": state}, query={}, get=lambda key: "owner") + + +@pytest.mark.asyncio +async def test_a_turn_published_before_the_switch_refuses_it( + tmp_path, repo, switch_boundary, monkeypatch +): + """Ordering one: the turn is on its slot first, so the busy check sees it.""" + switch = MagicMock(return_value=({"ok": True, "branch": "feature"}, 200)) + monkeypatch.setattr(gb, "_switch_sync", switch) + slot = _ChatSlot("chat-1") + slot.project = str(repo) + gate = asyncio.Event() + slot.task = asyncio.create_task(gate.wait()) + state = SimpleNamespace(_slots={slot.key: slot}, subagents=None) + try: + response = await gb.api_project_git_switch(_switch_request(state)) + finally: + gate.set() + await slot.task + assert response.status == 409 + switch.assert_not_called() + assert not guard.is_reserved(switch_boundary) + + +def _slow_switch(monkeypatch): + started = threading.Event() + finish = threading.Event() + + def slow_switch(root, branch, create, track): + started.set() + assert finish.wait(default_timeout()) + return {"ok": True, "branch": branch}, 200 + + monkeypatch.setattr(gb, "_switch_sync", slow_switch) + return started, finish + + +@pytest.mark.asyncio +async def test_a_switch_is_audited_once_when_its_request_waits(repo, switch_boundary, monkeypatch): + started, finish = _slow_switch(monkeypatch) + finish.set() + response = await gb.api_project_git_switch(_switch_request(SimpleNamespace(_slots={}))) + assert response.status == 200 + gb.sel.return_value.log_api_access.assert_called_once() + assert gb.sel.return_value.log_api_access.call_args.kwargs["outcome"] == "allowed" + + +@pytest.mark.asyncio +async def test_a_switch_finishing_after_its_request_is_cancelled_is_still_audited( + repo, switch_boundary, monkeypatch +): + """The checkout outlives a cancelled request (shutdown, client gone); the branch + still changed, so the audit log must still record it.""" + started, finish = _slow_switch(monkeypatch) + request = asyncio.create_task( + gb.api_project_git_switch(_switch_request(SimpleNamespace(_slots={}))) + ) + await async_wait_until(started.is_set, describe=lambda: "the checkout worker never started") + request.cancel() + finish.set() + (result,) = await await_until(asyncio.gather(request, return_exceptions=True)) + assert isinstance(result, asyncio.CancelledError) + gb.sel.return_value.log_api_access.assert_called_once() + entry = gb.sel.return_value.log_api_access.call_args.kwargs + assert entry["operation"] == "project_git_switch" and entry["outcome"] == "allowed" + assert not guard.is_reserved(switch_boundary) + + +@pytest.mark.asyncio +async def test_a_turn_admitted_during_the_busy_probe_waits_for_git( + tmp_path, repo, switch_boundary, monkeypatch +): + """Ordering two, the reported race: a turn starts while the sub-agent probe is + suspended, after the probe had nothing to see; it must not run beside git.""" + from kiro_crew.dashboard import chat_utils + + reached = _stop_at_setup(monkeypatch) + probing = asyncio.Event() + probe_done = asyncio.Event() + + class _Subagents: + async def has_pending_work_for_async(self, key): + probing.set() + await probe_done.wait() + return False + + started = threading.Event() + finish = threading.Event() + + def slow_switch(root, branch, create, track): + started.set() + assert finish.wait(default_timeout()) + return {"ok": True, "branch": branch}, 200 + + monkeypatch.setattr(gb, "_switch_sync", slow_switch) + monkeypatch.setattr(chat_utils, "effective_session_key", lambda slot: f"dashboard:{slot.key}") + slot = _ChatSlot("chat-1") + slot.project = str(repo / "sub") + switch_state = SimpleNamespace(_slots={slot.key: slot}, subagents=_Subagents()) + turn_state = _make_state(tmp_path) + turn_state._slots[slot.key] = slot + + request = asyncio.create_task(gb.api_project_git_switch(_switch_request(switch_state))) + await await_until(probing) + event = _SeenEvent() + guard._reserved[switch_boundary] = event # same reservation, observable waiters + turn = asyncio.create_task(chat_runner._run_chat(turn_state, slot, "edit a file")) + await await_until(event.waiting) + probe_done.set() + await async_wait_until(started.is_set, describe=lambda: "the checkout worker never started") + assert reached == [] and not turn.done() and not event.is_set() + + finish.set() + response = await await_until(request) + assert response.status == 200 + with pytest.raises(_Stop): + await await_until(turn) + assert reached == ["setup"] + + +def _orphan(): + """A grandchild whose parent run's card was dismissed, so its record is gone.""" + return _run("g1", cwd=os.path.join(os.sep, "unrelated", "place"), parent="subagent:dismissed") + + +def test_a_dismissed_ancestor_makes_the_lineage_unknown(): + state = SimpleNamespace(_slots={}, subagents=SimpleNamespace(all_agents=[])) + with pytest.raises(readers.LineageUnknown): + readers.subagent_folders(state, _orphan()) + + +@pytest.mark.asyncio +async def test_busy_check_counts_a_run_with_an_unknown_lineage(repo): + orphan = _orphan() + state = SimpleNamespace( + _slots={}, subagents=SimpleNamespace(running=[orphan], all_agents=[orphan]) + ) + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is True + + +@pytest.mark.asyncio +async def test_a_run_with_an_unknown_lineage_waits_out_every_checkout(repo, tmp_path): + state = SimpleNamespace(_slots={}, subagents=SimpleNamespace(all_agents=[])) + elsewhere = os.path.realpath(tmp_path / "other") + event = _reserve_seen(elsewhere) + waiter = asyncio.create_task(readers.wait_for_subagent_checkout(state, _orphan())) + await await_until(event.waiting) + assert not waiter.done() + guard.release(elsewhere) + await await_until(waiter) + + +# ── Workflow steps: sessions opened outside chat slots and the sub-agent manager ── + + +def _no_slots(): + return SimpleNamespace(_slots={}, subagents=None) + + +@pytest.mark.asyncio +async def test_tracked_work_is_busy_everywhere_until_placed_then_where_it_runs(repo, tmp_path): + state, root = _no_slots(), os.path.realpath(repo) + async with guard.tracked_work(waiter="workflow r1") as work: + # Its session could land anywhere until it reports a folder. + assert await gb._repo_has_running_work(state, root) is True + # A session that reports no folder leaves it unplaced, even with a prompt + # project recorded beside it. + await work.place(SimpleNamespace(cwd=""), str(tmp_path / "other")) + assert await gb._repo_has_running_work(state, root) is True + await work.place(SimpleNamespace(cwd=str(tmp_path / "other"))) + assert await gb._repo_has_running_work(state, root) is False + # The prompt is built against the repo though the session runs elsewhere. + await work.place(SimpleNamespace(cwd=str(tmp_path / "other")), str(repo / "sub")) + assert await gb._repo_has_running_work(state, root) is True + assert await gb._repo_has_running_work(state, root) is False + assert guard.tracked_work_folders() == ([], True) + + +@pytest.mark.asyncio +async def test_tracked_work_waits_out_any_checkout_then_one_of_its_folder(repo, tmp_path): + elsewhere = os.path.realpath(tmp_path / "other") + first = _reserve_seen(elsewhere) + steps: list[str] = [] + go = asyncio.Event() + + async def step(): + async with guard.tracked_work(waiter="workflow r1") as work: + steps.append("entered") + await go.wait() + await work.place(SimpleNamespace(cwd=str(repo / "sub"))) + steps.append("placed") + + task = asyncio.create_task(step()) + await await_until(first.waiting) + # Registered unplaced before it waits, so a switch arriving now refuses. + assert steps == [] and guard.tracked_work_folders() == ([], False) + guard.release(elsewhere) + await async_wait_until(lambda: steps == ["entered"]) + root = os.path.realpath(repo) + second = _reserve_seen(root) + go.set() + await await_until(second.waiting) + assert steps == ["entered"] + guard.release(root) + await await_until(task) + assert steps == ["entered", "placed"] and guard.tracked_work_folders() == ([], True) + + +class _WfProvider: + def __init__(self, cwd: str) -> None: + self.cwd = cwd + self.at_reset: list[tuple[list[str], bool]] = [] + + async def new_conversation(self) -> None: + self.at_reset.append(guard.tracked_work_folders()) + + def is_process_alive(self) -> bool: + return True + + +class _WfSessions: + """A session manager double that records the tracked state at each claim.""" + + def __init__(self, cwd: str) -> None: + self.cwd = cwd + self.at_claim: list[tuple[list[str], bool]] = [] + + async def get_or_create(self, key, **_kwargs): + self.at_claim.append(guard.tracked_work_folders()) + return _WfProvider(self.cwd), True, False + + def release(self, key, *, cleanup=False): + return None + + async def destroy(self, key): + return None + + async def reset(self, key, **_kwargs): + return None + + async def end_children_for(self, key): + return None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["cold", "pooled", "named"]) +async def test_a_workflow_step_is_tracked_while_its_session_runs(repo, tmp_path, monkeypatch, path): + """Every way a step reaches a session: the cold per-call builder, a warm + pooled worker, and the pool's unpooled leg for a named ``session=``. The + session runs in one folder and the prompt is built against the run's + project, so both are recorded.""" + from kiro_crew.workflows import agent_exec, agent_pool + + elsewhere, project = str(tmp_path / "other"), str(repo / "sub") + sessions = _WfSessions(elsewhere) + during: list[tuple[list[str], bool]] = [] + + async def run_step(*_args, **_kwargs): + during.append(guard.tracked_work_folders()) + return "done" + + monkeypatch.setattr(agent_exec, "stream_and_collect", run_step) + monkeypatch.setattr(agent_pool, "_run_step", run_step) + monkeypatch.setattr("kiro_crew.messaging.identity.publish_turn_identity", AsyncMock()) + monkeypatch.setattr(agent_pool, "publish_turn_identity", AsyncMock()) + pool = None + if path == "cold": + fn = agent_exec.build_agent_fn(sessions, run_id="r1", cwd=project) + opts: dict = {} + else: + fn, pool = agent_pool.build_pooled_agent_fn(sessions, run_id="r1", cwd=project) + opts = {"session": "named"} if path == "named" else {} + try: + assert await fn("do the step", opts) is not None + finally: + if pool is not None: + await pool.shutdown() + # Unplaced while the session was being claimed, then placed where it runs. + assert sessions.at_claim == [([], False)] + assert len(during) == 1 and during[0][1] is True + assert set(during[0][0]) == {elsewhere, project} + assert guard.tracked_work_folders() == ([], True) + + +@pytest.mark.asyncio +async def test_a_pooled_worker_reset_is_tracked(repo, monkeypatch): + """Reusing a warm worker opens a new conversation before the step's turn.""" + from kiro_crew.workflows import agent_pool + + sessions = _WfSessions(str(repo / "sub")) + worker = agent_pool._WorkflowSessionWorker( + sessions, key="wf-pool:r1:0", agent=None, model=None, cwd=None + ) + await worker.start() + assert guard.tracked_work_folders() == ([], True) + await worker.reset() + assert worker._provider.at_reset == [([], False)] + assert guard.tracked_work_folders() == ([], True) + + +class _RetainingSessions(_WfSessions): + """Records every session a step ends, so a test can see none was.""" + + def __init__(self, cwd: str) -> None: + super().__init__(cwd) + self.destroyed: list[str] = [] + self.reset_keys: list[str] = [] + + async def destroy(self, key): + self.destroyed.append(key) + + async def destroy_if(self, key, generation, should_destroy): + self.destroyed.append(key) + return True + + async def reset(self, key, **_kwargs): + self.reset_keys.append(key) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ending", ["raised", "cancelled"]) +@pytest.mark.parametrize("path", ["cold", "named"]) +async def test_a_failed_named_workflow_step_keeps_its_session(repo, monkeypatch, path, ending): + """A named workflow session keeps its conversation on success, exception and + cancellation (docs/system-specs/modules/workflows.md): the branch switch + leans on the abandoned prompt's own record, not on ending the session.""" + from kiro_crew.workflows import agent_exec, agent_pool + + sessions = _RetainingSessions(str(repo / "sub")) + + async def turn(*_args, **_kwargs): + if ending == "cancelled": + raise asyncio.CancelledError + raise RuntimeError("turn failed") + + monkeypatch.setattr(agent_exec, "stream_and_collect", turn) + monkeypatch.setattr(agent_pool, "stream_and_collect", turn) + monkeypatch.setattr("kiro_crew.messaging.identity.publish_turn_identity", AsyncMock()) + monkeypatch.setattr(agent_pool, "publish_turn_identity", AsyncMock()) + pool = None + if path == "cold": + fn = agent_exec.build_agent_fn(sessions, run_id="r1") + else: + fn, pool = agent_pool.build_pooled_agent_fn(sessions, run_id="r1") + expected = asyncio.CancelledError if ending == "cancelled" else RuntimeError + try: + with pytest.raises(expected): + await fn("do the step", {"session": "kept"}) + finally: + if pool is not None: + await pool.shutdown() + assert sessions.destroyed == [] and sessions.reset_keys == [] + assert not guard._tracked + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ending", ["raised", "cancelled", "cut_off"]) +@pytest.mark.parametrize("path", ["cold", "pooled", "named"]) +async def test_an_abandoned_step_keeps_its_project_busy_while_its_prompt_is_unanswered( + repo, tmp_path, monkeypatch, path, ending +): + """A reused session can run in another repository than the step's project. + The prompt's own record names only the session's folder, so the step's + project stays counted through the prompt until the backend answers it or + its process tree is confirmed gone -- not only until the step ends.""" + from kiro_crew.workflows import agent_exec, agent_pool + + elsewhere, project = str(tmp_path / "other"), str(repo / "sub") + sessions = _WfSessions(elsewhere) + + async def turn(*_args, **_kwargs): + # What a transport does when the step's turn goes out. + await guard.before_prompt(elsewhere) + guard.prompt_sent("proc-1", 7, elsewhere, method="session/prompt") + if ending == "cancelled": + raise asyncio.CancelledError + if ending == "cut_off": + return "stopped at the tool-call ceiling" + raise RuntimeError("turn failed") + + for module in (agent_exec, agent_pool): + monkeypatch.setattr(module, "stream_and_collect", turn, raising=False) + monkeypatch.setattr(agent_pool, "_run_step", turn) + monkeypatch.setattr("kiro_crew.messaging.identity.publish_turn_identity", AsyncMock()) + monkeypatch.setattr(agent_pool, "publish_turn_identity", AsyncMock()) + pool = None + if path == "cold": + fn = agent_exec.build_agent_fn(sessions, run_id="r1", cwd=project) + opts: dict = {} + else: + fn, pool = agent_pool.build_pooled_agent_fn(sessions, run_id="r1", cwd=project) + opts = {"session": "named"} if path == "named" else {} + try: + if ending == "cut_off": + await fn("do the step", opts) + else: + expected = asyncio.CancelledError if ending == "cancelled" else RuntimeError + with pytest.raises(expected): + await fn("do the step", opts) + finally: + if pool is not None: + await pool.shutdown() + assert not guard._tracked + folders, placed = guard.tracked_work_folders() + assert placed and {elsewhere, project} <= set(folders) + assert await gb._repo_has_running_work(_no_slots(), os.path.realpath(repo)) + if ending == "raised": + guard.prompt_process_gone("proc-1") + else: + guard.prompt_answered("proc-1", 7) + assert guard.tracked_work_folders() == ([], True) + assert not await gb._repo_has_running_work(_no_slots(), os.path.realpath(repo)) + + +def _finished_run_state(repo): + """A real manager holding one DONE run whose folder is in ``repo``.""" + manager = SubagentManager(sessions=MagicMock(), ctx_builder=MagicMock()) + info = SubagentInfo(id="0123abcd4567ef89", task="child", cwd=str(repo / "sub")) + info.done = True + manager._agents[info.id] = info + return SimpleNamespace(_slots={}, subagents=manager), manager, info + + +async def _live_task(): + return asyncio.ensure_future(asyncio.sleep(3600)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("holder", ["task", "recovery_task", "teardown_gate", "kill_handles"]) +async def test_a_finished_run_stays_busy_until_its_teardown_is_over(repo, holder): + """``done`` flips before the run's teardown and a reap's kill finish.""" + state, manager, info = _finished_run_state(repo) + root = os.path.realpath(repo) + assert info not in manager.running + task = None + if holder in ("task", "recovery_task"): + task = await _live_task() + key = info.id if holder == "task" else f"{info.id}:recovery" + manager._tasks[key] = task + elif holder == "teardown_gate": + manager._teardown_gates[info.id] = asyncio.Event() + else: + manager._process_handles[info.id] = [] + try: + assert await gb._repo_has_running_work(state, root) is True + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + # Once nothing of the run is left, its folder is free again. + manager._tasks.clear() + manager._teardown_gates.clear() + manager._process_handles.clear() + assert await gb._repo_has_running_work(state, root) is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("registry", ["_teardown_gates", "_process_handles", "_tasks"]) +async def test_a_tearing_down_run_with_no_record_counts_everywhere(repo, tmp_path, registry): + """A dashboard clear can pop the record while the teardown still runs.""" + state, manager, _info = _finished_run_state(tmp_path / "elsewhere") + task = None + if registry == "_tasks": + task = await _live_task() + manager._tasks["fedcba9876543210"] = task + else: + getattr(manager, registry)["fedcba9876543210"] = asyncio.Event() + try: + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is True + finally: + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_an_announcement_task_is_not_run_work(repo, tmp_path): + state, manager, _info = _finished_run_state(tmp_path / "elsewhere") + task = await _live_task() + manager._tasks["reject-fedcba9876543210"] = task + try: + assert await gb._repo_has_running_work(state, os.path.realpath(repo)) is False + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) diff --git a/test/test_state_parts_facade.py b/test/test_state_parts_facade.py index c80af5fb788..67b1ba96405 100644 --- a/test/test_state_parts_facade.py +++ b/test/test_state_parts_facade.py @@ -408,6 +408,8 @@ "_channel_folder_filed": ("value", "False"), "_channel_runtime_origin": ("value", "False"), "_channel_window_mtime": ("value", "0.0"), + "_checkout_turn_projects": ("value", "()"), + "_checkout_turn_unplaced": ("value", "False"), "_chunk_seq": ("value", "0"), "_closing": ("value", "0"), "_compacting": ("value", "False"), diff --git a/website/scripts/capture-branch-switcher.mjs b/website/scripts/capture-branch-switcher.mjs new file mode 100644 index 00000000000..29d954d1db9 --- /dev/null +++ b/website/scripts/capture-branch-switcher.mjs @@ -0,0 +1,381 @@ +/** + * Screenshot harness for the Git panel's branch switcher. + * + * Runs the REAL built SPA (website/dist) with /api/** answered from fixtures. + * The branch fixture is stateful: a POST to /api/project/git/switch moves + * `current`, so the frames show the header and the list following a switch. + * + * Frames: + * 1-closed header shows the checked-out branch with a chevron + * 2-open picker: safety line, local (newest first, ahead pill) + remote sections + * 3-filtered typed query narrowing the list, plus the create row + * 4-switched header after switching to a local branch + * 5-dirty 409 git_switch_dirty rendered as a notice naming the target branch and the files in the way + * 6-blocked switchBlocked: "filter" explains why, and every row is muted + * 7-light-open the open picker in the light theme, safety line included + * 8-chip-closed the composer's project chip with its branch segment + * 9-chip-open the same picker opened upward from the composer chip + * 10-chip-switched chip and panel header both follow a switch made from the chip + * 11-chip-light-open the composer picker in the light theme + * 12-chip-hover the idle composer chip under the pointer (hover fill) + * 13-chip-busy while a response runs the chip is a copy control (copy glyph) + * 14-chip-busy-copied the same chip after a click: name copied, check glyph + * and the visible note saying how to switch + * 15-ahead-behind-tooltip the open picker with the pointer on a row's ↑N pill + * 16-light-dirty the dirty refusal in the light theme (safety line hidden) + * 17-chip-busy-copy-failed the busy chip after a click the clipboard refused + * 18-create-highlighted the create row highlighted (arrowed to), showing its + * key hint, which no other state draws + * 19-no-matches a query that matches no branch and is not a valid new name: + * the empty list says why + * 20-session-busy 409 git_switch_session_busy: an agent is still working in + * this repository + * 21-switched-back the acknowledgment after its "Switch back to …" button + * returned to the branch checked out before + * + * Frames 4 and 10 also carry the "Switched to …" acknowledgment with its + * "Switch back to …" button, and frame 13 + * the busy reason the chip shows under the pointer. + * + * Usage: node scripts/capture-branch-switcher.mjs + */ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { openTranscriptHarness } from './lib/transcript-harness.mjs' +import { json } from './lib/boot-api.mjs' + +const OUT = process.argv[2] || '../temp-screenshots/branch-switcher' +const SLOT = 'chat-branch-switcher' +const PROJECT = '/home/user/workspace/demo-service' + +mkdirSync(OUT, { recursive: true }) + +const slots = [{ + key: SLOT, + title: 'Branch switcher', + running: false, + last_message: 'Switch branches from the Changes panel.', + messages: 2, + agent: 'kirocrew', + memory_mode: 'persistent', + project: PROJECT, + modified: Math.floor(Date.now() / 1000), + source_links: [], + source_links_total: 0, +}] + +const detail = { + running: false, + has_more: false, + total: 2, + queue: [], + project: PROJECT, + messages: [ + { role: 'user', ts: Date.now() / 1000 - 60, content: 'Can I switch branches from the Changes tab?' }, + { role: 'assistant', ts: Date.now() / 1000 - 30, content: 'Yes: click the branch name in the panel header.' }, + ], +} + +const h = await openTranscriptHarness({ + slot: SLOT, + project: PROJECT, + slots, + detail, + viewport: { width: 1400, height: 820 }, + deviceScaleFactor: 1, +}) + +const ago = s => new Date(Date.now() - s * 1000).toISOString() +const row = (name, s, subject, extra = {}) => ({ + name, date: ago(s), author: 'Gray Smith', subject, switchable: true, ...extra, +}) + +let current = 'develop' +let mode = 'ok' +const localRows = () => [ + row('develop', 3600, 'Add Namespace Governance tab to AI SDLC dashboard', { ahead: 1 }), + row('sdlc-0911', 6 * 86400, 'Add SCTE 2026 speaking materials, webinar diagrams'), + row('second-brain', 90 * 86400, 'Add sea-shell skills'), + row('feature/dev-backup-042426', 210 * 86400, 'Update docs'), + row('feature/main-backup', 400 * 86400, "Merge branch 'develop' into 'main'"), + row('main', 400 * 86400, "Merge branch 'develop' into 'main'", { behind: 3 }), +].map(r => ({ ...r, current: r.name === current })) + +const dashCfg = { auto_open_git_panel: true } +await h.page.route(/\/api\/dashboard\/config$/, async route => json(route, dashCfg)) +// The chat runs on this machine (no session environment), so the Git tab shows +// this machine's repository rather than waiting to learn where the chat runs. +await h.page.route(/\/api\/chat\/slots\/[^/]+\/environment$/, async route => + json(route, { profile: null, profiles: [], locked: true })) + +await h.page.route(/\/api\/project\/git/, async route => { + const req = route.request() + const path = new URL(req.url()).pathname + if (path === '/api/project/git/repos') { + // The Git tab lists this chat's repositories; the project's section is the + // one whose heading carries the switcher. + return json(route, { repos: [{ path: PROJECT, source: 'project' }], listed: 0, omitted: 0, incomplete: false }) + } + if (path === '/api/project/git/status') { + return json(route, { + repo: true, + repoRoot: PROJECT, + branch: current, + ahead: current === 'develop' ? 1 : undefined, + files: [ + { path: 'resources/ai-tools/kirocrew-questions-feedback.md', status: 'M', staged: false, additions: 16, deletions: 0 }, + { path: 'maps/MOC-tools.md', status: 'M', staged: false, additions: 3, deletions: 0 }, + { path: 'areas/dev-ai-platform/kiro-administration-transition-plan.html', status: '?', staged: false, additions: 128 }, + ], + }) + } + if (path === '/api/project/git/log') { + // Each branch has its own history, so a frame taken after a switch shows + // the Commits list change with it. Commit ids are derived from the branch + // name, so no id appears on two branches with different messages. + const tip = localRows().find(r => r.name === current) + const sha = salt => { + let x = 2166136261 + for (const ch of `${current}:${salt}`) x = Math.imul(x ^ ch.charCodeAt(0), 16777619) >>> 0 + return x.toString(16).padStart(8, '0').slice(0, 7) + } + return json(route, { + repo: true, + commits: [ + { sha: sha('tip'), message: tip?.subject ?? 'Initial commit', author: 'Gray Smith', date: tip?.date ?? ago(6 * 86400), isHead: true }, + { sha: sha('parent'), message: current === 'develop' ? 'chore: ignore .kiro/settings and add program overview backup' : `Start ${current}`, author: 'Gray Smith', date: ago((tip ? (Date.now() - Date.parse(tip.date)) / 1000 : 6 * 86400) + 86400), isHead: false }, + ], + }) + } + if (path === '/api/project/git/branches') { + return json(route, { + repo: true, + current, + local: localRows(), + remote: [row('origin/feature/ai-deck-v5', 30 * 86400, 'Refactor DocsWithQPhing layout')], + ...(mode === 'blocked' ? { switchBlocked: 'filter' } : {}), + }) + } + if (path === '/api/project/git/switch') { + const body = JSON.parse(req.postData() || '{}') + if (mode === 'dirty') { + // The files in the way are edits the Changes list in the same frame shows. + return json(route, { error: 'Your uncommitted changes would be overwritten by this switch.', code: 'git_switch_dirty', files: ['resources/ai-tools/kirocrew-questions-feedback.md', 'maps/MOC-tools.md'], files_more: 0 }, 409) + } + if (mode === 'busy') { + return json(route, { error: 'Another session working in this repository is still running.', code: 'git_switch_session_busy' }, 409) + } + current = body.branch + return json(route, { ok: true, branch: current }) + } + return json(route, { repo: true, repoRoot: PROJECT, branch: current }) +}) + +const trigger = '[data-testid="branch-switcher-trigger"][data-variant="header"]' +const chipTrigger = '[data-testid="branch-switcher-trigger"][data-variant="chip"]' +const pop = '[data-testid="branch-switcher"]' + +async function boot(theme = 'dark') { + await h.load(theme, { selector: 'textarea', settle: 1500 }) + // The dashboard no longer opens the Git panel on its own, so seed the side + // panel open on its Git tab and reload so the chat rehydrates it. An init + // script, registered after load()'s storage-clearing one, because init + // scripts re-run on reload (a page.evaluate seed would be wiped). + await h.page.addInitScript(slot => { + localStorage.setItem('mc-activity-open:' + slot, 'true') + localStorage.setItem('mc-privacy-notice-v1', '1') + localStorage.setItem('mc-panel-tabs:' + slot, JSON.stringify({ + tabs: [{ id: 'changes', kind: 'changes', title: 'Changes' }, { id: 'git', kind: 'git', title: 'Git' }], + activeId: 'git', + })) + }, SLOT) + await h.page.reload({ waitUntil: 'domcontentloaded' }) + await h.page.waitForSelector('textarea', { timeout: 20000 }) + try { + await h.page.waitForSelector(trigger, { timeout: 15000 }) + } catch (e) { + // Diagnostic frame only: named so it can never be mistaken for evidence. + await h.page.screenshot({ path: join(OUT, 'DEBUG-no-header-trigger.png') }) + throw e + } + await h.page.waitForTimeout(400) +} + +async function shot(name) { + await h.page.waitForTimeout(300) + await h.page.screenshot({ path: join(OUT, `${name}.png`) }) + console.log('SHOT', name) +} + +async function openPicker() { + await h.page.click(trigger) + await h.page.waitForSelector(`${pop} [data-testid="branch-row-local"]`, { timeout: 15000 }) +} + +await boot() +await shot('1-closed') + +await openPicker() +await h.page.waitForSelector('[data-testid="branch-switcher-safety"]', { timeout: 15000 }) +console.log('SAFETY', await h.page.locator('[data-testid="branch-switcher-safety"]').textContent()) +// The list overflows here (the Remote section sits below its edge), so the +// scroll-edge cue must be drawn: a frame without it would hide the cue. +await h.page.waitForSelector('[data-testid="branch-switcher-more-below"]', { timeout: 5000 }) +await shot('2-open') + +await h.page.keyboard.type('feat') +await shot('3-filtered') +// Arrow down to the create row: its key hint shows only while it is highlighted. +for (let i = 0; i < 12; i++) { + if (await h.page.locator(`${pop} [data-testid="branch-create"][aria-selected="true"]`).count()) break + await h.page.keyboard.press('ArrowDown') +} +await h.page.waitForSelector(`${pop} [data-testid="branch-create-shortcut"]`, { timeout: 5000 }) +console.log('CREATE', await h.page.locator(`${pop} [data-testid="branch-create"]`).textContent()) +await shot('18-create-highlighted') +await h.page.keyboard.press('Escape') + +await openPicker() +// A query that names no branch and no valid new one: the list is empty, and +// its one line says why rather than drawing nothing. +await h.page.keyboard.type('bad..name') +await h.page.waitForSelector(`${pop} :text("Not a valid branch name")`, { timeout: 5000 }) +await shot('19-no-matches') +await h.page.keyboard.press('Escape') + +await openPicker() +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'sdlc-0911' }).dispatchEvent('mousedown') +await h.page.waitForSelector(pop, { state: 'detached', timeout: 15000 }) +await h.page.waitForSelector(`${trigger}:has-text("sdlc-0911")`, { timeout: 15000 }) +console.log('SWITCHED', await h.page.locator(trigger).textContent()) +await h.page.waitForSelector('[data-testid="branch-switcher-note"][data-note="switched"]', { timeout: 5000 }) +console.log('ACK', await h.page.locator('[data-testid="branch-switcher-note"]').textContent()) +await shot('4-switched') +// The acknowledgment carries the way back: one click returns to the branch +// that was checked out before. +const back = h.page.locator('[data-testid="branch-switch-back"]') +console.log('BACK', await back.textContent()) +await back.click() +await h.page.waitForFunction(() => { + const note = document.querySelector('[data-testid="branch-switcher-note"][data-note="switched"]') + return !!note && !note.textContent.startsWith('Switched to sdlc-0911') +}, null, { timeout: 15000 }) +await h.page.waitForSelector(`${trigger}:has-text("develop")`, { timeout: 15000 }) +console.log('BACK ACK', await h.page.locator('[data-testid="branch-switcher-note"]').textContent()) +await shot('21-switched-back') + +mode = 'dirty' +await openPicker() +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'second-brain' }).dispatchEvent('mousedown') +await h.page.waitForSelector('[data-testid="branch-switcher-error"]', { timeout: 15000 }) +const dirtyText = await h.page.locator('[data-testid="branch-switcher-error"]').textContent() +console.log('DIRTY', dirtyText) +if (!dirtyText.includes('second-brain')) throw new Error('dirty notice does not name the target branch') +if (await h.page.locator('[data-testid="branch-switcher-safety"]').count()) throw new Error('safety line shown beside a refusal') +await shot('5-dirty') +await h.page.keyboard.press('Escape') + +mode = 'busy' +await openPicker() +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'second-brain' }).dispatchEvent('mousedown') +await h.page.waitForSelector('[data-testid="branch-switcher-error"]', { timeout: 15000 }) +console.log('BUSY', await h.page.locator('[data-testid="branch-switcher-error"]').textContent()) +await shot('20-session-busy') +await h.page.keyboard.press('Escape') + +mode = 'blocked' +await h.page.evaluate(() => {}) +await boot() +await openPicker() +await h.page.waitForSelector('[data-testid="branch-switcher-blocked"]', { timeout: 15000 }) +// Pointer over a row: a muted row takes no highlight. +await h.page.hover(`${pop} [data-testid="branch-row-local"]:nth-of-type(2)`) +const unmuted = await h.page.locator(`${pop} [role="option"]:not([data-muted])`).count() +console.log('BLOCKED unmuted rows:', unmuted, '|', await h.page.locator('[data-testid="branch-switcher-blocked"]').textContent()) +if (unmuted) throw new Error('a filter-blocked row is not muted') +await shot('6-blocked') + +mode = 'ok' +await boot('light') +await openPicker() +await shot('7-light-open') +await h.page.keyboard.press('Escape') + +// A row's ahead/behind pill under the pointer: the native tooltip is not drawn +// in a headless screenshot, so the frame shows the pill and the log its title. +await boot() +await openPicker() +const pill = h.page.locator(`${pop} [data-testid="branch-ahead-behind"]`).first() +await pill.hover() +console.log('PILL TITLE', await pill.getAttribute('title')) +await h.page.waitForTimeout(1200) +await shot('15-ahead-behind-tooltip') +await h.page.keyboard.press('Escape') + +mode = 'dirty' +await boot('light') +await openPicker() +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'main' }).last().dispatchEvent('mousedown') +await h.page.waitForSelector('[data-testid="branch-switcher-error"]', { timeout: 15000 }) +if (await h.page.locator('[data-testid="branch-switcher-safety"]').count()) throw new Error('safety line shown beside a refusal') +await shot('16-light-dirty') +await h.page.keyboard.press('Escape') +mode = 'ok' + +// The composer's project chip opens the same picker, upward. +current = 'develop' +await boot() +await h.page.waitForSelector(`${chipTrigger}:has-text("develop")`, { timeout: 15000 }) +await shot('8-chip-closed') +await h.page.click(chipTrigger) +await h.page.waitForSelector(`${pop}[data-placement="above"] [data-testid="branch-row-local"]`, { timeout: 15000 }) +await shot('9-chip-open') +await h.page.locator(`${pop} [data-testid="branch-row-local"]`, { hasText: 'second-brain' }).dispatchEvent('mousedown') +await h.page.waitForSelector(pop, { state: 'detached', timeout: 15000 }) +await h.page.waitForSelector(`${chipTrigger}:has-text("second-brain")`, { timeout: 15000 }) +await h.page.waitForSelector(`${trigger}:has-text("second-brain")`, { timeout: 15000 }) +await h.page.waitForSelector('[data-testid="branch-switcher-note"][data-note="switched"]', { timeout: 5000 }) +console.log('CHIP SWITCHED', await h.page.locator(chipTrigger).textContent(), '| header:', await h.page.locator(trigger).textContent()) +await shot('10-chip-switched') + +await boot('light') +await h.page.click(chipTrigger) +await h.page.waitForSelector(`${pop} [data-testid="branch-row-local"]`, { timeout: 15000 }) +await shot('11-chip-light-open') +await h.page.keyboard.press('Escape') + +current = 'develop' +await boot() +await h.page.waitForSelector(`${chipTrigger}:has-text("develop")`, { timeout: 15000 }) +await h.page.hover(chipTrigger) +await shot('12-chip-hover') + +// A running response: the chip stays enabled and copies instead of switching. +slots[0].running = true +detail.running = true +await boot() +await h.page.waitForSelector(`${chipTrigger}[data-mode="copy"]`, { timeout: 15000 }) +await h.page.hover(chipTrigger) +await h.page.waitForSelector('[data-testid="branch-switcher-note"][data-note="busy"]', { timeout: 5000 }) +console.log('BUSY', await h.page.locator(chipTrigger).getAttribute('aria-label'), '|', await h.page.locator('[data-testid="branch-switcher-note"]').textContent()) +await shot('13-chip-busy') +await h.page.click(chipTrigger) +await h.page.waitForSelector(`${chipTrigger}[aria-label^="Copied"]`, { timeout: 5000 }) +if (await h.page.locator(pop).count()) throw new Error('picker opened while a response runs') +console.log('BUSY COPIED', await h.page.locator(chipTrigger).getAttribute('aria-label'), '|', await h.page.locator('[data-testid="branch-switcher-note"]').textContent()) +await shot('14-chip-busy-copied') + +// The clipboard refuses: both copy layers fail, so the chip shows an error notice. +await h.page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.reject(new Error('denied')) } }) + document.execCommand = () => false +}) +await h.page.mouse.move(5, 5) +await h.page.waitForTimeout(2800) +await h.page.click(chipTrigger) +await h.page.waitForSelector('[data-testid="branch-switcher-copy-error"]', { timeout: 5000 }) +console.log('COPY FAILED', await h.page.locator('[data-testid="branch-switcher-copy-error"]').textContent()) +await h.page.mouse.move(5, 5) +await shot('17-chip-busy-copy-failed') + +await h.close() diff --git a/website/src/api/client/files.ts b/website/src/api/client/files.ts index 2e6d8072ed8..550a57ee44e 100644 --- a/website/src/api/client/files.ts +++ b/website/src/api/client/files.ts @@ -78,6 +78,32 @@ export const FILE_SEARCH_TIMEOUT_MS = 15_000 * them. */ export const BROWSE_FILES_TIMEOUT_MS = 10_000 +/** One row of `GET /api/project/git/branches`. `switchable` is false when the + * name came back redacted, so echoing it to the switch route could not work. */ +export interface GitBranchRow { + name: string + date: string + author: string + subject: string + switchable: boolean + current?: boolean + /** Commits ahead of / behind the branch's upstream; absent when untracked or equal. */ + ahead?: number + behind?: number +} + +export interface GitBranchList { + repo: boolean + current?: string | null + detached?: boolean + head?: string + local: GitBranchRow[] + remote: GitBranchRow[] + truncated?: boolean + /** Present when checkout is refused for this repository (a declared filter driver). */ + switchBlocked?: 'filter' +} + export function createFilesEndpoints({ post, put, del, j, jfetch: fetch, checkSessionExpired, withJournaledDeadline }: ClientTransport) { const projects = { // Follow-up card: create a sibling git worktree of `repo` on a new `branch`. @@ -128,6 +154,11 @@ export function createFilesEndpoints({ post, put, del, j, jfetch: fetch, checkSe * `listed` counts the agent rows shown and `omitted` the ones past the cap. * `incomplete` means bounded tool-path tracking may have missed repositories. */ projectGitRepos: (slot: string) => fetch('/api/project/git/repos?slot=' + encodeURIComponent(slot)).then(j) as Promise<{ repos: { path: string; source: 'project' | 'agent' }[]; omitted: number; listed: number; incomplete: boolean }>, + /** Local branches (newest commit first) plus remote branches with no local twin. See `handlers/git_branches.py`. */ + projectGitBranches: (path: string) => fetch('/api/project/git/branches?path=' + encodeURIComponent(path)).then(j) as Promise, + /** Check out `branch`; `create` makes it at HEAD, `track` makes it tracking `/`. Owner-only. */ + projectGitSwitch: (body: { path: string; branch: string; create?: boolean; track?: string }) => + post('/api/project/git/switch', body).then(j) as Promise<{ ok: true; branch: string }>, /** `dir` lists one project-relative folder (one the whole listing named in * `truncatedDirectories`) instead of the whole tree; its paths stay project-relative. */ projectTree: (path: string, dir?: string) => withJournaledDeadline(FILE_SEARCH_TIMEOUT_MS, undefined, '/api/project/tree', s => diff --git a/website/src/components/BranchSwitcher.tsx b/website/src/components/BranchSwitcher.tsx new file mode 100644 index 00000000000..2bb9d73aff4 --- /dev/null +++ b/website/src/components/BranchSwitcher.tsx @@ -0,0 +1,932 @@ +import { useEffect, useId, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import { createPortal } from 'react-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, ChevronDown, Copy, GitBranch, Globe, Loader2, Plus, Search, Undo2 } from 'lucide-react' +import { api } from '../api/client' +import type { GitBranchRow } from '../api/client/files' +import ErrorNotice from './ErrorNotice' +import { Btn } from './ui' +import CopyBranchButton from './CopyBranchButton' +import { useCloseWhenCrewHidden, useOverlayRoot } from './overlayRoot' +import { useListKeyboardNav } from '../hooks/useListKeyboardNav' +import { useImeGuard } from '../hooks/useImeGuard' +import { copyToClipboard } from '../utils/clipboard' +import { gitErrorCode } from '../utils/gitStatusError' +import { findReport } from '../utils/errorReport' +import { errMessage } from '../utils/thunkError' +import { timeAgo } from '../utils/timeAgo' +import { i18nT } from '../i18n/t' +import { isMac } from '../utils/platform' + +/** Same grammar the switch route applies to a NEW name (`is_valid_followup_branch`), + * so the create row is offered only for a name the server will accept. */ +const NEW_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/ +const WINDOWS_DEVICE_STEMS = new Set([ + 'con', 'prn', 'aux', 'nul', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', +]) + +/** Which rule a NEW branch name breaks, or `null` when the switch route would + * accept it. The picker says the broken rule, since "not a valid name" alone + * leaves the reader guessing what to change. */ +export type NewBranchNameProblem = 'length' | 'chars' | 'dots' | 'reserved' + +export function newBranchNameProblem(name: string): NewBranchNameProblem | null { + if (!name) return 'chars' + if (name.length > 200) return 'length' + if (!NEW_BRANCH_RE.test(name)) return 'chars' + if (name === 'HEAD') return 'reserved' + const parts = name.split('/') + if (name.includes('..') || parts.some(part => part.endsWith('.') || part.endsWith('.lock'))) return 'dots' + if (parts.some(part => WINDOWS_DEVICE_STEMS.has(part.split('.')[0].toLowerCase()))) return 'reserved' + return null +} + +export function isValidNewBranchName(name: string): boolean { + return newBranchNameProblem(name) === null +} + +const NAME_RULE_KEYS: Record = { + length: 'components.branchSwitcher.name_rule_length', + chars: 'components.branchSwitcher.name_rule_chars', + dots: 'components.branchSwitcher.name_rule_dots', + reserved: 'components.branchSwitcher.name_rule_reserved', +} + +/** Localized copy for each coded switch failure. The backend's English sentence is + * never shown under a localized surface; an uncoded failure falls back to the + * generic line with git's own first line as detail. */ +const SWITCH_ERROR_KEYS: Record = { + git_switch_dirty: 'components.branchSwitcher.error_dirty', + git_branch_in_worktree: 'components.branchSwitcher.error_in_worktree', + git_branch_exists: 'components.branchSwitcher.error_exists', + git_operation_in_progress: 'components.branchSwitcher.error_in_progress', + git_switch_session_busy: 'components.branchSwitcher.error_session_busy', + git_branch_not_found: 'components.branchSwitcher.error_not_found', + invalid_branch: 'components.branchSwitcher.error_invalid', + git_switch_filter_refused: 'components.branchSwitcher.switch_blocked_filter', + git_sandbox_unavailable: 'components.branchSwitcher.error_sandbox', + repo_root_outside_project: 'components.branchSwitcher.outside_project', + git_timeout: 'components.branchSwitcher.error_timeout', +} + +function errorDetail(error: unknown): string | undefined { + const body = (error as { body?: unknown } | null)?.body + if (typeof body !== 'string' || !body.trim().startsWith('{')) return undefined + try { + const parsed = JSON.parse(body) as { detail?: unknown } + return typeof parsed.detail === 'string' && parsed.detail ? parsed.detail : undefined + } catch { + return undefined + } +} + +/** The files a dirty-tree refusal names, joined for display; '' when it names none. */ +function errorFiles(error: unknown): string { + const body = (error as { body?: unknown } | null)?.body + if (typeof body !== 'string' || !body.trim().startsWith('{')) return '' + try { + const parsed = JSON.parse(body) as { files?: unknown; files_more?: unknown } + const files = Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string' && !!f) : [] + if (!files.length) return '' + const more = typeof parsed.files_more === 'number' && parsed.files_more > 0 ? parsed.files_more : 0 + // A locale-neutral "+N" for the rest, so no plural forms are needed. + return more ? `${files.join(', ')} +${more}` : files.join(', ') + } catch { + return '' + } +} + +/** Retries a clipboard write that failed; the notice beside it names the text. */ +function RetryCopyButton({ onClick, testId }: { onClick: () => void; testId: string }) { + return ( + + ) +} + +type Flash = + /** `previous` is the branch checked out before the switch, offered as a + * one-click way back; absent when HEAD was detached or unknown. */ + | { kind: 'switched'; branch: string; previous?: string; project: string } + | { kind: 'copy_failed'; name: string } + +/** How long the switch acknowledgment stays beside the trigger. */ +const SWITCHED_MS = 4000 +/** With a way back on it, long enough to reach the button. */ +const SWITCHED_UNDO_MS = 10000 +/** A failure stays longer, so it can be read and its text selected. */ +const COPY_FAILED_MS = 8000 + +type Choice = + | { kind: 'local'; row: GitBranchRow } + | { kind: 'remote'; row: GitBranchRow } + | { kind: 'create'; name: string } + +function choiceKey(c: Choice): string { + return c.kind === 'create' ? `create:${c.name}` : `${c.kind}:${c.row.name}` +} + +/** `/` -> ``: the local branch a tracked remote row becomes. */ +function localNameFor(remote: string): string { + const slash = remote.indexOf('/') + return slash >= 0 ? remote.slice(slash + 1) : remote +} + +/** What a row's ahead/behind arrows count, in words. */ +function aheadBehindText(row: GitBranchRow): string { + return [ + row.ahead ? i18nT('components.branchSwitcher.ahead_count', { count: row.ahead }) : '', + row.behind ? i18nT('components.branchSwitcher.behind_count', { count: row.behind }) : '', + ].filter(Boolean).join(', ') +} + +/** Where the transient note sits relative to the trigger. Under the Git panel + * header it drops below the trigger. The composer chip sits in the shelf + * BELOW the composer, so a note above it would cover the composer's own + * action row (mic, send) while the user wants to type: there it sits on the + * chip's own row instead, to the right when it fits, else to the left, and + * only above when neither side has room (a very narrow window). */ +export function notePlacement( + variant: 'header' | 'chip', + anchor: Pick, + noteWidth: number, +): { top?: number; bottom?: number; left?: number; right?: number } { + const gap = 6 + const vw = window.innerWidth + if (variant === 'chip') { + const bottom = window.innerHeight - anchor.bottom + if (anchor.right + gap + noteWidth <= vw - 8) return { bottom, left: anchor.right + gap } + if (anchor.left - gap - noteWidth >= 8) return { bottom, right: vw - anchor.left + gap } + return { bottom: window.innerHeight - anchor.top + 4, left: Math.max(8, Math.min(anchor.left, vw - noteWidth - 8)) } + } + return { top: anchor.bottom + 4, left: Math.max(8, Math.min(anchor.left, vw - noteWidth - 8)) } +} + +export interface BranchSwitcherProps { + projectDir: string + /** The branch the status route reports, shown on the trigger while the list loads. */ + branch?: string + /** `header` is the Git panel's title control and opens downward; `chip` is the + * composer shelf's branch segment, which sits at the bottom of the page and + * so opens upward. */ + variant?: 'header' | 'chip' + /** True while this chat's response runs. Switching is blocked then, since a + * checkout would change files under the turn, but reading the branch name is + * harmless: the trigger stays enabled and copies the name instead of opening + * the picker, and its tooltip says how to switch. */ + switchBlocked?: boolean + /** `branch` is a short commit, not a branch: HEAD is detached. Only changes the + * noun the copy control announces. */ + detached?: boolean +} + +/** + * The branch control used by the Git panel header and the composer's project + * chip: shows the checked-out branch and opens a filterable picker to switch to + * a local branch, check out a remote one as a tracking branch, or create a new + * branch from HEAD by typing its name. + * + * The list is read only while the picker is open. A switch refreshes every view + * of the working tree (status, log, file tree, the composer's branch chip), + * since all of them change with the checkout. + */ +export default function BranchSwitcher({ projectDir, branch, variant = 'header', switchBlocked = false, detached = false }: BranchSwitcherProps) { + const qc = useQueryClient() + const ime = useImeGuard() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [pending, setPending] = useState(null) + /** The failed switch, with the branch it targeted so the notice can name it. */ + const [switchError, setSwitchError] = useState<{ error: unknown; target: string; create: boolean } | null>(null) + const [copied, setCopied] = useState(false) + /** The footer's copy button was refused the clipboard: the name it tried. */ + const [footerCopyFailed, setFooterCopyFailed] = useState(null) + const copiedTimer = useRef | null>(null) + /** A transient note anchored to the trigger: a switch that landed, or a copy + * the browser refused. The picker is closed in both cases, so it cannot + * carry them. */ + const [flash, setFlash] = useState(null) + const flashTimer = useRef | null>(null) + /** Pointer over, or keyboard focus on, the trigger. Only read while switching + * is blocked, when the trigger shows why in a visible hint. */ + const [hinting, setHinting] = useState(false) + const triggerRef = useRef(null) + const popRef = useRef(null) + const listRef = useRef(null) + /** Rows sit below the list's scroll edge. A section heading can land right on + * that edge with its rows out of view, which reads as an empty section, so + * the edge fades out to say the list goes on. */ + const [moreBelow, setMoreBelow] = useState(false) + const measureList = () => { + const el = listRef.current + setMoreBelow(!!el && el.scrollTop + el.clientHeight < el.scrollHeight - 1) + } + const reasonId = useId() + const safetyId = useId() + useEffect(() => () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current) + if (flashTimer.current) clearTimeout(flashTimer.current) + }, []) + const showFlash = (next: Flash) => { + if (flashTimer.current) clearTimeout(flashTimer.current) + setFlash(next) + const ms = next.kind === 'copy_failed' + ? COPY_FAILED_MS + : next.previous ? SWITCHED_UNDO_MS : SWITCHED_MS + flashTimer.current = setTimeout(() => setFlash(null), ms) + } + + const { data, error: listError, isLoading } = useQuery({ + queryKey: ['git-branches', projectDir], + queryFn: () => api.projectGitBranches(projectDir), + enabled: open && !!projectDir, + staleTime: 5_000, + retry: 1, + }) + + const close = () => { + setOpen(false) + setQuery('') + setSwitchError(null) + setFooterCopyFailed(null) + ime.reset() + } + // A Crew switch hides the overlay root; the picker closes like a click outside. + useCloseWhenCrewHidden(open, close) + const overlayRoot = useOverlayRoot() + + // Click outside closes; deferred a tick so the opening click does not. + useEffect(() => { + if (!open) return + let detach = () => {} + const timer = setTimeout(() => { + const onDown = (e: MouseEvent) => { + const t = e.target as Node + if (popRef.current?.contains(t) || triggerRef.current?.contains(t)) return + close() + } + document.addEventListener('mousedown', onDown) + detach = () => document.removeEventListener('mousedown', onDown) + }, 0) + return () => { clearTimeout(timer); detach() } + // `close` only resets local state; re-attaching per render would churn the listener. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const blockedCode = data?.switchBlocked === 'filter' + ? 'git_switch_filter_refused' + : gitErrorCode(listError) === 'repo_root_outside_project' + ? 'repo_root_outside_project' + : undefined + const q = query.trim() + const ql = q.toLowerCase() + + const choices = useMemo(() => { + if (!data?.repo) return [] + const match = (r: GitBranchRow) => !ql || r.name.toLowerCase().includes(ql) + const out: Choice[] = [ + ...data.local.filter(match).map(row => ({ kind: 'local' as const, row })), + ...data.remote.filter(match).map(row => ({ kind: 'remote' as const, row })), + ] + const exists = data.local.some(r => r.name === q) + // A filter-blocked repository refuses a create like any other checkout, so + // the create row is not offered there at all rather than shown inert. + if (q && !exists && isValidNewBranchName(q) && data.switchBlocked !== 'filter') out.push({ kind: 'create', name: q }) + return out + }, [data, q, ql]) + + // Short names that more than one remote carries; those rows keep their + // `/` prefix so they stay distinguishable. + const ambiguousRemoteNames = useMemo(() => { + const seen = new Set() + const dup = new Set() + for (const r of data?.remote ?? []) { + const short = localNameFor(r.name) + if (seen.has(short)) dup.add(short) + seen.add(short) + } + return dup + }, [data]) + const selectable = (c: Choice) => + !blockedCode && !pending && (c.kind === 'create' || (c.row.switchable && !(c.kind === 'local' && c.row.current))) + + // The write goes through React Query's mutation lifecycle; the row key and + // the refusal stay in local state, since they name which row is pending. + const switchMutation = useMutation({ + mutationFn: (req: Parameters[0]) => api.projectGitSwitch(req), + }) + const choose = async (c: Choice) => { + if (!selectable(c)) return + const req = c.kind === 'local' + ? { path: projectDir, branch: c.row.name } + : c.kind === 'remote' + ? { path: projectDir, branch: localNameFor(c.row.name), track: c.row.name } + : { path: projectDir, branch: c.name, create: true } + setPending(choiceKey(c)) + setSwitchError(null) + const target = req.branch + // The way back the acknowledgment offers: the branch checked out now. + // Only a name the list marked switchable, so a redacted one is never echoed. + const previous = data?.repo && !data.detached && data.current && data.current !== target && + data.local.some(r => r.name === data.current && r.switchable) + ? data.current + : undefined + try { + const result = await switchMutation.mutateAsync(req) + await Promise.all([ + qc.invalidateQueries({ queryKey: ['git-status', projectDir] }), + qc.invalidateQueries({ queryKey: ['git-log', projectDir] }), + qc.invalidateQueries({ queryKey: ['git-branches', projectDir] }), + qc.invalidateQueries({ queryKey: ['project-tree', projectDir] }), + qc.invalidateQueries({ queryKey: ['project-git'] }), + ]) + close() + showFlash({ kind: 'switched', branch: result?.branch || target, previous, project: projectDir }) + } catch (err) { + setSwitchError({ error: err, target, create: c.kind === 'create' }) + // A switch back started from the acknowledgment has no picker open to + // show its refusal in, so it opens one. + if (flashTimer.current) clearTimeout(flashTimer.current) + setFlash(null) + setOpen(true) + } finally { + setPending(null) + } + } + + /** The acknowledgment's way back: the same local switch a row click makes. */ + const switchBack = (previous: string) => { + if (switchBlocked || pending) return + // The note stays up, its button spinning, until the switch lands (a new + // acknowledgment replaces it) or is refused (the picker opens with why). + if (flashTimer.current) clearTimeout(flashTimer.current) + void choose({ + kind: 'local', + row: { name: previous, date: '', author: '', subject: '', switchable: true }, + }) + } + + const nav = useListKeyboardNav({ + open, + count: choices.length, + // Enter and Tab switch to an existing branch, but never create one: a + // search that matches nothing would otherwise turn a typo into a new + // branch plus a checkout. Creating takes ⌘/Ctrl+Enter or a click. + onChoose: (i, withModifier) => { + const c = choices[i] + if (!c || (c.kind === 'create' && !withModifier)) return + void choose(c) + }, + onClose: close, + }) + // Start on the first existing branch Enter can act on: the checked-out + // branch sorts near the top and is never a target, and the create row needs + // its own shortcut. Only a list with nothing else starts on the create row. + useEffect(() => { + const first = choices.findIndex(c => c.kind !== 'create' && c.row.switchable && !(c.kind === 'local' && c.row.current)) + const create = choices.findIndex(c => c.kind === 'create') + nav.setSelected(Math.max(0, first >= 0 ? first : create)) + }, [query, data]) // eslint-disable-line react-hooks/exhaustive-deps + // Re-read the scroll edge whenever the rows change under it, and whenever + // the rows' own height does (web fonts landing after the first paint grow + // every row, which can push a heading onto the edge with no state change). + useEffect(() => { + if (!open) return + measureList() + const el = listRef.current + if (!el || typeof ResizeObserver === 'undefined') return + const ro = new ResizeObserver(() => measureList()) + ro.observe(el) + for (const child of Array.from(el.children)) ro.observe(child) + return () => ro.disconnect() + }, [open, choices, isLoading]) + + const label = branch ?? data?.current ?? (data?.detached && data.head + ? i18nT('components.branchSwitcher.detached', { sha: data.head }) + : '') + // What a new branch starts from: the checked-out branch, or the commit when + // none is checked out. `git switch -c` branches from HEAD. + const createBase = data?.current ?? (data?.detached ? data.head : undefined) ?? '' + + const switchErrorCode = gitErrorCode(switchError?.error) + const switchErrorKey = switchErrorCode ? SWITCH_ERROR_KEYS[switchErrorCode] : undefined + const switchErrorDetail = errorDetail(switchError?.error) + const switchErrorFiles = switchErrorCode === 'git_switch_dirty' ? errorFiles(switchError?.error) : '' + const switchErrorMessage = switchErrorKey + ? switchErrorFiles + ? `${i18nT(switchErrorKey)} ${i18nT('components.branchSwitcher.dirty_files', { files: switchErrorFiles })}` + : i18nT(switchErrorKey) + : undefined + // The promise of what a switch does to local edits, shown above the list + // except beside a refusal (which says what happened instead), in a + // filter-blocked repository (its own notice), and outside a repository. + const showSafety = !blockedCode && !switchError && !(data && !data.repo) + + // A response starting while the picker is open closes it, since a switch + // would change files under the turn. + useEffect(() => { + if (switchBlocked && open) close() + }, [switchBlocked]) // eslint-disable-line react-hooks/exhaustive-deps + + const copyLabel = async () => { + if (!label) return + if (!(await copyToClipboard(label))) { + setCopied(false) + showFlash({ kind: 'copy_failed', name: label }) + return + } + setFlash(null) + setCopied(true) + if (copiedTimer.current) clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), 2500) + } + const onTrigger = () => { + if (switchBlocked) void copyLabel() + else if (open) close() + else setOpen(true) + } + // While switching is blocked the trigger is a copy control: its name says + // what a click does, and its description adds how to switch. That reason is + // also drawn as a visible note on hover, on focus and after a copy, so it + // does not depend on a delayed native tooltip (which keyboard focus and touch + // never show). A note beside the trigger fits the narrow composer shelf, + // where an always-on line of text would not. + const triggerName = switchBlocked + ? detached + ? i18nT(copied ? 'components.branchSwitcher.copied_commit' : 'components.branchSwitcher.copy_commit', { sha: label }) + : i18nT(copied ? 'components.branchSwitcher.copied_branch_name' : 'components.branchSwitcher.copy_branch_name', { branch: label }) + : i18nT('components.branchSwitcher.switch_branch_current', { branch: label }) + const triggerTitle = switchBlocked + ? i18nT(detached ? 'components.branchSwitcher.copy_commit_while_running' : 'components.branchSwitcher.copy_branch_while_running') + : undefined + const TriggerIcon = switchBlocked ? (copied ? Check : Copy) : ChevronDown + // While switching is blocked the trigger itself looks busy (muted text and + // glyph, a copy glyph, a copy cursor), so the change of job shows before any + // hover note does. All of it is static: it lasts the whole response, in the + // reader's peripheral vision. A just-confirmed copy keeps its check. + const busyLook = switchBlocked && !copied + const hintHandlers = { + onMouseEnter: () => setHinting(true), + onMouseLeave: () => setHinting(false), + onFocus: () => setHinting(true), + onBlur: () => setHinting(false), + } + const switchedText = flash?.kind === 'switched' + ? i18nT('components.branchSwitcher.switched_to', { branch: flash.branch }) + : '' + // A response that started since the switch blocks switching back too. + // Only in the repository the switch was made in. + const undoTarget = flash?.kind === 'switched' && !switchBlocked && flash.project === projectDir + ? flash.previous + : undefined + const busyHint = switchBlocked && !open && (hinting || copied) + ? (copied ? i18nT('components.branchSwitcher.copied_while_running') : triggerTitle) + : undefined + const showNote = !open && (!!flash || !!busyHint) + const anchor = showNote ? triggerRef.current?.getBoundingClientRect() : undefined + // Wider with the way-back button, so the note stays one line on the chip's + // row instead of growing up into the composer's action row. + const noteWidth = Math.min(flash?.kind === 'switched' && undoTarget ? 400 : 280, window.innerWidth - 16) + + const rect = open ? triggerRef.current?.getBoundingClientRect() : undefined + // 360px, narrowed on a viewport too small to fit it beside the 8px gutters. + const width = Math.min(360, window.innerWidth - 16) + const above = variant === 'chip' + const placement = rect + ? above + ? { bottom: window.innerHeight - rect.top + 4, maxHeight: Math.max(220, Math.min(460, rect.top - 12)) } + : { top: rect.bottom + 4, maxHeight: Math.max(220, Math.min(460, window.innerHeight - rect.bottom - 12)) } + : undefined + + const renderRow = (c: Choice, i: number) => { + const active = i === nav.selected + const isPending = pending === choiceKey(c) + const enabled = selectable(c) + // A filter-blocked repository takes every row out of play, so the rows say + // so visually too: dimmed, and no highlight under the pointer or keyboard. + const muted = !!blockedCode + const key = choiceKey(c) + const common = { + id: `branch-opt-${i}`, + role: 'option' as const, + 'aria-selected': active, + 'aria-disabled': !enabled || undefined, + tabIndex: -1, + ref: (el: HTMLButtonElement | null) => { nav.itemRefs.current[i] = el }, + onMouseEnter: () => nav.setSelected(i), + onMouseDown: (e: ReactMouseEvent) => { + e.preventDefault() + // Only the primary button picks a row: a right- or middle-click must + // not switch branches or create one. + if (e.button !== 0) return + void choose(c) + }, + 'data-muted': muted || undefined, + className: `w-full text-left px-3 py-1.5 flex items-start gap-2 border-none transition-colors ${ + active && !muted ? 'bg-bg-hover' : 'bg-transparent'} ${enabled ? 'cursor-pointer' : 'cursor-default'} ${muted ? 'opacity-50' : ''}`, + } + if (c.kind === 'create') { + // Set apart from the branch rows so it does not read as "the first + // result", and labelled with what the new branch starts from, so the + // search text reads as a branch being made rather than a query. The key + // hint appears only once the row is highlighted, where it answers "how do + // I make it", instead of standing under every search as a warning. + return ( + + {isPending ? : } + + + + {createBase + ? i18nT('components.branchSwitcher.create_branch_from', { name: c.name, base: createBase }) + : i18nT('components.branchSwitcher.create_branch', { name: c.name })} + + {active && ( + + {i18nT(isMac ? 'components.branchSwitcher.create_shortcut_mac' : 'components.branchSwitcher.create_shortcut_other')} + + )} + + + ) + } + const row = c.row + const isCurrent = c.kind === 'local' && row.current + const when = row.date ? timeAgo(Date.parse(row.date) / 1000) : '' + // A remote row's click creates a local branch, which its name alone does + // not say, so the row states it on a visible line of its own. + const remoteAction = c.kind === 'remote' && row.switchable + ? i18nT('components.branchSwitcher.remote_row_action', { name: localNameFor(row.name) }) + : '' + // Under the Remote heading the `/` prefix adds nothing a reader + // needs, so the title is the branch name alone. The full name is kept when + // two remotes carry the same branch, where the prefix is what tells them + // apart. + const rowTitle = c.kind === 'remote' && !ambiguousRemoteNames.has(localNameFor(row.name)) + ? localNameFor(row.name) + : row.name + return ( + + ) + } + + const localCount = choices.filter(c => c.kind === 'local').length + const showArrowLegend = choices.some(c => c.kind !== 'create' && (!!c.row.ahead || !!c.row.behind)) + const remoteStart = localCount + const remoteCount = choices.filter(c => c.kind === 'remote').length + + return ( + <> + {variant === 'chip' ? ( + // Composer shelf segment, styled like the folder segment beside it: + // full-strength muted text that brightens with a hover fill, so it reads + // as a control. mousedown is cancelled so a click does not pull focus out + // of the composer before the picker's own input takes it. + + ) : ( + + )} + {/* The copy-mode reason, read as the trigger's description. */} + {switchBlocked ? triggerTitle : ''} + {/* Mounted before it has anything to say, so a landed switch is + announced politely when its text arrives. */} + {switchedText} + {showNote && anchor && createPortal( +
+ {flash?.kind === 'copy_failed' ? ( + /* No hand-off: this control sits in the composer's shelf and the + chat's Git panel, and the hand-off moves the chat to a new + session, away from a composer draft that is not sent yet (the + composer's own notices leave it off for the same reason). The + notice names the text, so it can be selected and copied by hand. */ +
+ + void copyLabel()} testId="branch-switcher-copy-retry" /> +
+ ) : flash?.kind === 'switched' ? ( + + {/* The live region above announces it; this is the visible copy. */} + + {undoTarget && ( + e.preventDefault()} + onClick={() => switchBack(undoTarget)} + disabled={!!pending} + aria-busy={!!pending || undefined} + data-testid="branch-switch-back" + className="gap-1 px-1.5 py-0.5 text-[11px]" + > + {pending ? : + )} + + ) : ( + + )} +
, + overlayRoot, + )} + {open && rect && placement && createPortal( +
+
+
+ + { setQuery(e.target.value); setSwitchError(null) }} + {...ime.bindComposition()} + className="w-full bg-bg-elevated border border-border rounded pl-7 pr-3 py-1.5 text-[12px] font-mono text-text placeholder:text-muted placeholder:font-sans focus:outline-hidden focus-visible:border-accent disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+
+ + {(blockedCode || switchError || listError) ? ( +
+ {/* No hand-off: the hand-off navigates away and unmounts this + picker, discarding the branch name typed in its search field + (the draft "Create branch" acts on). */} + {blockedCode ? ( + + ) : switchError ? ( + + ) : ( + + )} +
+ ) : null} + + {/* States the guarantee the switch route gives first (git refuses any + checkout that would overwrite local work), then what a switch + does, so a row reads as safe to click. Left out beside a refusal, which + says what happened instead, and in a filter-blocked repository, + which shows its own notice. */} + {showSafety && ( +

+ {i18nT('components.branchSwitcher.safe_switch_hint')} +

+ )} + +
+
+ {isLoading ? ( +
+ + {i18nT('components.branchSwitcher.loading')} +
+ ) : data && !data.repo ? ( +
{i18nT('components.gitPanel.not_a_repository')}
+ ) : data && choices.length === 0 ? ( +
+ {q && newBranchNameProblem(q) + ? i18nT('components.branchSwitcher.invalid_new_name', { + rule: i18nT(NAME_RULE_KEYS[newBranchNameProblem(q) as NewBranchNameProblem]), + }) + : i18nT('components.branchSwitcher.no_matches')} +
+ ) : ( + <> + {localCount > 0 && ( +
+ {i18nT('components.branchSwitcher.local_branches')} +
+ )} + {choices.slice(0, localCount).map((c, i) => renderRow(c, i))} + {remoteCount > 0 && ( + // The count says the section has rows even when they sit + // below the list's scroll edge. +
+ {i18nT('components.branchSwitcher.remote_branches')} ({remoteCount}) +
+ )} + {choices.slice(remoteStart, remoteStart + remoteCount).map((c, i) => renderRow(c, remoteStart + i))} + {choices.slice(remoteStart + remoteCount).map((c, i) => renderRow(c, remoteStart + remoteCount + i))} + + )} +
+ {moreBelow && ( + // A strip of its own below the list, not an overlay: a fade drawn + // over the last visible row made that branch look disabled. + + )} +
+ + {/* The footer copy sits inside the open picker, so its failure is + told here rather than by the trigger's transient note. No + hand-off, for the same reason as the notices above the list. */} + {footerCopyFailed && ( +
+ + { + const name = footerCopyFailed + void copyToClipboard(name).then(ok => setFooterCopyFailed(ok ? null : name)) + }} + testId="branch-switcher-footer-copy-retry" + /> +
+ )} + + {(data?.current || data?.truncated) && ( +
+ {data.current && ( + + {i18nT('components.branchSwitcher.current_branch')} + setFooterCopyFailed(ok ? null : data.current ?? null)} + /> + + )} + + {/* The row pills are bare arrows, so the footer says once what + they count, on a line of its own so it never squeezes the + current branch's name. Left out beside the truncation note + and when no row has a pill. */} + {showArrowLegend && !data.truncated && ( + + {i18nT('components.branchSwitcher.ahead_behind_legend')} + + )} + {data.truncated && ( + + {i18nT('components.branchSwitcher.truncated', { limit: 200 })} + + )} +
+ )} +
, + overlayRoot, + )} + + ) +} diff --git a/website/src/components/ChatInput.tsx b/website/src/components/ChatInput.tsx index 78daba6285b..f3081558ece 100644 --- a/website/src/components/ChatInput.tsx +++ b/website/src/components/ChatInput.tsx @@ -5,9 +5,9 @@ import { markComposerResize } from '../utils/composerResize' import { ArrowUp, Loader2, RotateCw, Sparkles, Target, CheckCircle, Lock, FolderOpen, ClipboardList, PenLine, MoreHorizontal, Terminal, Clock, X } from 'lucide-react' import SketchDialog from './SketchDialog' import QuoteCard from '../pages/chat/QuoteCard' -import CopyBranchButton from './CopyBranchButton' import ProjectAwsProfileStatus from './ProjectAwsProfile' import { useSlotRemoteEnvironment } from './sessionEnvironmentHooks' +import BranchSwitcher from './LazyBranchSwitcher' import RejectDropdown from './RejectDropdown' import { OverlayPortal } from './overlayRoot' import { useQueryClient } from '@tanstack/react-query' @@ -1805,7 +1805,7 @@ function ChatInput({ {onProjectClick && !remoteEnvironment && ( /* Two sibling buttons inside one visual pill, NOT a nested button: the folder segment opens the project picker and the branch segment - copies. A + {switchable &&
{statusRow}
} + {refreshButton} {requestErrors} diff --git a/website/src/components/GitReposPanel.tsx b/website/src/components/GitReposPanel.tsx index 521441eb2b8..6722272e0c8 100644 --- a/website/src/components/GitReposPanel.tsx +++ b/website/src/components/GitReposPanel.tsx @@ -14,6 +14,8 @@ interface GitReposPanelProps { /** The chat whose repositories are listed. */ slotKey: string onFileOpen?: (path: string) => void + /** True while this chat's response runs: a checkout would change files under the turn. */ + busy?: boolean } /** How many open sections poll their status on the interval at once. */ @@ -28,7 +30,7 @@ export const MAX_POLLING_SECTIONS = 3 * tab lists them without the user picking a project folder first. The chat's * project, when it is in a repository, comes first. */ -export default function GitReposPanel({ slotKey, onFileOpen }: GitReposPanelProps) { +export default function GitReposPanel({ slotKey, onFileOpen, busy = false }: GitReposPanelProps) { const { data, error, isLoading, isFetching, refetch } = useQuery({ queryKey: ['git-repos', slotKey], queryFn: () => api.projectGitRepos(slotKey), @@ -158,7 +160,11 @@ export default function GitReposPanel({ slotKey, onFileOpen }: GitReposPanelProp projectDir={repo.path} onFileOpen={onFileOpen} onOpenChange={handleOpenChange} + busy={busy} section={{ + // Only the chat's project: the switch route accepts slot + // projects, not the repositories a tool call merely touched. + branchSwitch: repo.source === 'project', label: repo.path, badge: repo.source === 'project' ? i18nT('components.gitPanel.source_project') : undefined, badgeTitle: repo.source === 'project' ? i18nT('components.gitPanel.source_project_help') : undefined, diff --git a/website/src/components/LazyBranchSwitcher.tsx b/website/src/components/LazyBranchSwitcher.tsx new file mode 100644 index 00000000000..e1b14004fdd --- /dev/null +++ b/website/src/components/LazyBranchSwitcher.tsx @@ -0,0 +1,52 @@ +import { lazy, Suspense } from 'react' +import { ChevronDown, Copy, GitBranch } from 'lucide-react' +import type { BranchSwitcherProps } from './BranchSwitcher' +import { i18nT } from '../i18n/t' + +// The picker (list, filter, keyboard model, switch flow) is ~17 KB that only +// matters once someone opens it, while both of its hosts -- the composer's +// project chip and the Git panel header -- sit in the always-loaded chat-route +// chunk. Loading it behind import() keeps that chunk inside its bundle-size +// budget (scripts/check-bundle-size.mjs). +const BranchSwitcher = lazy(() => import('./BranchSwitcher')) + +/** + * Same box as BranchSwitcher's trigger, so the chip and the header do not shift + * when the real control replaces it. Not interactive: it is on screen only for + * the moment the picker's chunk takes to arrive on first render. + */ +function TriggerPlaceholder({ branch, variant = 'header', switchBlocked = false }: Pick) { + const Icon = switchBlocked ? Copy : ChevronDown + if (variant === 'chip') { + return ( + + {branch} + + + ) + } + return ( + + + {branch || i18nT('components.gitPanel.loading')} + + + ) +} + +export default function LazyBranchSwitcher(props: BranchSwitcherProps) { + return ( + }> + {/* Keyed by repository: a switch's acknowledgment, its "Switch back" + target and any pending switch belong to the repository they were made + in, so moving the host to another project starts a fresh picker. */} + + + ) +} diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index 60a1b67da98..d2f083ca077 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -244,6 +244,30 @@ "components.filePickerMenu.folder": "aria-label on the folder ICON in the @-mention picker row and on the staged-folder chip in the composer. A directory in the file system, not a UI grouping.", "components.filePickerMenu.folder_kind": "Row metadata in the @-mention picker, rendered as 'folder · 3d ago' where a file row shows '12 KB · 3d ago'. Lowercase noun naming the entry kind (a file-system directory).", "components.folderColorNames.red": "Display name of the red folder color, interpolated into “Set color to {{name}}” on a swatch in the folder-settings color palette. The color adjective as a noun-like label.", + "components.branchSwitcher.local_branches": "Section heading in the Git branch picker over the list of LOCAL Git branches (as opposed to remote-tracking branches). Plural adjective agreeing with 'branches'.", + "components.branchSwitcher.copy_branch_while_running": "Tooltip on the branch control (under the chat box, and in the Git panel header) while the assistant is still answering: clicking it copies the branch name to the clipboard, and switching the project's Git branch needs the answer stopped first. 'Copy branch name' is what a click does now, not a heading. The second sentence names the stop control by its glyph ■ (keep it) and where it sits: beside the composer's Send button (use the same word as components.chatInput.send).", + "components.branchSwitcher.copy_commit_while_running": "Same tooltip as copy_branch_while_running when no branch is checked out (detached HEAD): clicking copies the short commit id. 'Commit' is the Git noun, a recorded revision, not the verb.", + "components.branchSwitcher.safe_switch_hint": "Note at the top of the Git branch picker, above the list. Leads with the guarantee in plain words: the user's changes are safe, a switch never overwrites files they changed but did not commit, and one that would changes nothing and names the files in the way. Then what a switch does: this folder's files show the chosen branch's version. No warning tone. Call the user's local work 'your changes' in every branch-picker string, and say once what that means (files changed but not committed) rather than using the bare Git term 'uncommitted'.", + "components.branchSwitcher.error_switch_to": "Bold lead of the error shown in the branch picker when switching failed; {{branch}} is the Git branch the user tried to switch to, never translated. The reason follows on the next line.", + "components.branchSwitcher.error_session_busy": "Branch picker failure reason: the switch was NOT made because an agent is still working in this repository, in this chat or another one. Leads with the outcome ('Not switched'), then the next step: wait for it to finish, or stop it in that chat with the stop button (■) next to Send, then switch. Keep ■ as is.", + "components.branchSwitcher.remote_branches": "Section heading in the Git branch picker over the list of REMOTE Git branches (branches on the server, e.g. origin/main). Plural adjective agreeing with 'branches'.", + "components.branchSwitcher.current_branch": "Label before the checked-out Git branch name in the branch picker footer (a click-to-copy chip follows it).", + "components.branchSwitcher.detached": "Git 'detached HEAD' state: no branch is checked out, HEAD points at commit {{sha}} directly.", + "components.branchSwitcher.truncated": "Footer note in the branch picker: only the {{limit}} most recently committed branches are listed. {{limit}} is a number.", + "components.branchSwitcher.error_dirty": "Branch picker failure reason: the switch was NOT made because this branch would overwrite some of the user's uncommitted changes. Leads with the outcome ('Not switched'), then a task-level next step: commit them, or ask the agent in this chat to commit or stash them, then switch again. The terminal commands come last in parentheses as a secondary hint (keep 'git commit' and 'git stash' verbatim). A list of the files in the way may follow. Here 'uncommitted changes' stays, since the next step ('Commit them') explains it.", + "components.branchSwitcher.switch_blocked_filter": "Notice in the branch picker when switching is disabled for this repository: it uses a checkout filter (a program Git runs on files whenever it switches branches), which the dashboard will not run. Leads with the outcome, names the checkout filter and says in plain words what it is, then the next step: switch in a terminal.", + "components.branchSwitcher.switched_to": "Short confirmation shown next to the branch control for a few seconds after a branch switch succeeded; {{branch}} is the Git branch now checked out, never translated. Past-tense statement of what happened.", + "components.branchSwitcher.copy_failed": "Error shown next to the branch control when copying to the clipboard failed (the browser refused clipboard access). {{name}} is the Git branch name or short commit id, never translated. Quote it the way your locale quotes a name, so it does not read as a word of the sentence.", + "components.branchSwitcher.copied_while_running": "Note shown next to the branch control right after a click copied the branch name while the assistant is still answering: confirms the copy, then says how to switch branch: stop the response first with the ■ button beside Send (keep the glyph; use the same word as components.chatInput.send).", + "components.branchSwitcher.ahead_count_one": "Tooltip and screen-reader text for the '↑N' badge on a row of the branch picker: the local branch has {{count}} commits its remote branch (the one it tracks) does not have. Plural on {{count}}. Say 'remote branch' (the same term as components.branchSwitcher.ahead_behind_legend), not 'upstream'. Singular form.", + "components.branchSwitcher.ahead_count_other": "Tooltip and screen-reader text for the '↑N' badge on a row of the branch picker: the local branch has {{count}} commits its remote branch (the one it tracks) does not have. Plural on {{count}}. Say 'remote branch' (the same term as components.branchSwitcher.ahead_behind_legend), not 'upstream'. Plural form.", + "components.branchSwitcher.behind_count_one": "Same as ahead_count for the '↓N' badge: the branch's remote branch (the one it tracks) has {{count}} commits this local branch does not have. Plural on {{count}}. Say 'remote branch', not 'upstream'. Singular form.", + "components.branchSwitcher.behind_count_other": "Same as ahead_count for the '↓N' badge: the branch's remote branch (the one it tracks) has {{count}} commits this local branch does not have. Plural on {{count}}. Say 'remote branch', not 'upstream'. Plural form.", + "components.branchSwitcher.create_shortcut_mac": "Key hint on the branch picker's create row (macOS), shown only while that row is highlighted: the key that creates the branch (plain Enter does not), or a click. Keep '⌘' and the key name 'Enter' as the keyboard labels them in your locale.", + "components.branchSwitcher.create_shortcut_other": "Same key hint as create_shortcut_mac on Windows and Linux. Use the Ctrl key's label on keyboards in your locale (German: Strg).", + "components.branchSwitcher.create_branch_from": "Label of the branch picker's create row: makes a new Git branch named {{name}} (the text typed in the search box) starting from {{base}} (the branch or commit checked out now). Both are Git names, never translated. Quote {{name}} the way your locale quotes a name.", + "components.branchSwitcher.remote_row_action": "Second line of a REMOTE branch row in the branch picker. Leads with the outcome ('Switch to {{name}}'), then that the branch is downloaded into this repository and the user's changes are kept. {{name}} is the Git branch name, never translated. Call the user's local work 'your changes' in every branch-picker string, and say once what that means (files changed but not committed) rather than using the bare Git term 'uncommitted'.", + "components.branchSwitcher.ahead_behind_legend": "Footer legend in the branch picker explaining the '↑N' and '↓N' badges on branch rows: commits ahead of / behind the remote branch this local branch tracks (Git calls it the upstream; say 'remote branch' instead). Keep the arrows; must stay short.", "components.jobForm.add": "Verb on the submit button of the scheduled-job form: add this job. Shown only when the form is horizontal and owns its own submit control, and it swaps to \"Adding…\" while the request is in flight. If the target language needs an object, name the job.", "components.logEntry.ok": "Status value in a table or log line, meaning the operation succeeded. A state, not the confirmation button.", "components.markdownRenderer.click_to_switch_to_this_session": "Hover tooltip on a small inline chip inside chat prose; the chip shows an agent-session identifier and clicking it switches the dashboard to that already-open chat session (an in-app tab switch, not a page navigation). \"Session\" here is a chat/agent conversation in a developer tool, never a login session.", @@ -896,6 +920,13 @@ "components.imageComments.box_label": "Accessible name of a box drawn on an image for an existing comment; {{number}} is the comment number in the pending list, and {{x}}, {{y}} are the box's top-left corner as horizontal and vertical percentages, already formatted. Activating it highlights that comment in the pending list.", "components.markdownPanel.new_badge": "Small badge before 'Tip:' on the file viewer's one-time comment tip, shown only when the tip is back for a reader who had closed its earlier, text-only wording: it marks that the tip now names a new feature (drawing a box on an image to comment on it). An adjective for the feature, not a verb like 'Create'; keep it to one short word.", "pages.mcpManagement.details_env": "Settings > MCP, a server row's Details: label before the list of environment variable NAMES the server's launch sets (never their values). Short for 'environment'.", - "pages.mcpManagement.details_new": "Settings > MCP, a paused server's Details and the Review dialog: heading over the server's NEW launch command, shown beside the 'Before' (previously approved) one. An adjective for the launch, not a 'create' action." + "pages.mcpManagement.details_new": "Settings > MCP, a paused server's Details and the Review dialog: heading over the server's NEW launch command, shown beside the 'Before' (previously approved) one. An adjective for the launch, not a 'create' action.", + "components.branchSwitcher.invalid_new_name": "Shown in the branch picker instead of a 'create branch' row when the typed text cannot be a new Git branch name; {{rule}} is one of the name_rule_* sentences, saying which rule the name breaks.", + "components.branchSwitcher.name_rule_chars": "Branch-name rule, shown after invalid_new_name: which characters a new branch name may use, and that each part between slashes starts with a letter or number. Keep the quoted characters exactly as they are.", + "components.branchSwitcher.name_rule_dots": "Branch-name rule, shown after invalid_new_name: the name may not contain two dots in a row, and no part between slashes may end in a dot or in '.lock'. Keep the quoted text exactly as it is.", + "components.branchSwitcher.name_rule_reserved": "Branch-name rule, shown after invalid_new_name: the name is reserved, either HEAD or a Windows device name such as CON. Keep HEAD and CON untranslated.", + "components.branchSwitcher.name_rule_length": "Branch-name rule, shown after invalid_new_name: the name is longer than 200 characters.", + "components.branchSwitcher.switch_back_to": "Button on the short confirmation shown after a branch switch: switches straight back to the branch that was checked out before. {{branch}} is that Git branch name, never translated. Imperative, short.", + "components.branchSwitcher.row_switch_hint": "Second line of the tooltip on a branch row in the branch picker, under the branch name: switching to this branch keeps the user's changes. Call the user's local work 'your changes' in every branch-picker string, and say once what that means (files changed but not committed) rather than using the bare Git term 'uncommitted'." } } diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 94402f5b05e..d5ca67aa0f5 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -9371,6 +9371,64 @@ "section_expand_hint": "পরিবর্তন ও ইতিহাস দেখো", "repos_retry": "আবার চেষ্টা করো" }, + "branchSwitcher": { + "switch_branch": "ব্রাঞ্চ বদলাও", + "switch_branch_current": "ব্রাঞ্চ বদলাও (বর্তমান: {{branch}})", + "search_label": "ব্রাঞ্চ ফিল্টার করো বা নতুন একটির নাম দাও", + "search_placeholder": "ব্রাঞ্চ খোঁজো বা নতুন একটির নাম দাও…", + "safe_switch_hint": "তোমার পরিবর্তনগুলো নিরাপদ: স্যুইচ করলে যে ফাইলগুলো তুমি বদলেছ কিন্তু কমিট করোনি, সেগুলো কখনো ওভাররাইট হয় না। যদি হতো, তাহলে কিছুই বদলাবে না এবং কোন ফাইলগুলো বাধা দিচ্ছে তা জানানো হবে। স্যুইচের পরে এই ফোল্ডারের ফাইলগুলো সেই ব্রাঞ্চের ভার্সনে দেখায়।", + "branches": "ব্রাঞ্চ", + "local_branches": "লোকাল", + "remote_branches": "রিমোট", + "remote_row_action": "{{name}}-এ স্যুইচ করো। এটি এখানে ডাউনলোড হবে; তোমার পরিবর্তনগুলো থেকে যাবে।", + "ahead_count_one": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট এগিয়ে", + "ahead_count_other": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট এগিয়ে", + "behind_count_one": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট পিছিয়ে", + "behind_count_other": "নিজের রিমোট ব্রাঞ্চের চেয়ে {{count}}টি কমিট পিছিয়ে", + "loading": "ব্রাঞ্চ লোড হচ্ছে…", + "load_failed": "ব্রাঞ্চের তালিকা লোড করা যায়নি।", + "no_matches": "কোনো মিলে যাওয়া ব্রাঞ্চ নেই", + "invalid_new_name": "বৈধ ব্রাঞ্চ নাম নয়। {{rule}}", + "create_branch": "নতুন ব্রাঞ্চ “{{name}}”", + "create_branch_from": "{{base}} থেকে নতুন ব্রাঞ্চ “{{name}}”", + "create_shortcut_mac": "তৈরি করতে ⌘ Enter চাপো বা ক্লিক করো", + "create_shortcut_other": "তৈরি করতে Ctrl+Enter চাপো বা ক্লিক করো", + "ahead_behind_legend": "↑ এগিয়ে · ↓ রিমোট ব্রাঞ্চের চেয়ে পিছিয়ে", + "detached": "{{sha}}-এ ডিট্যাচড", + "current_branch": "বর্তমান ব্রাঞ্চ", + "truncated": "শুধু সাম্প্রতিকতম {{limit}}টি তালিকাভুক্ত", + "not_switchable": "এই নামে একটি সিক্রেট আছে বলে মনে হচ্ছে, তাই এটি লুকানো এবং এখান থেকে এতে স্যুইচ করা যাবে না।", + "error_switch_to": "{{branch}}-এ স্যুইচ করা যায়নি", + "error_create": "{{branch}} ব্রাঞ্চ তৈরি করা যায়নি", + "error_dirty": "বদলানো হয়নি: এই ব্রাঞ্চ তোমার কমিট না করা কিছু পরিবর্তন ওভাররাইট করে দিত। সেগুলো কমিট করো, অথবা এই চ্যাটে এজেন্টকে সেগুলো কমিট বা stash করতে বলো, তারপর আবার বদলাও। (টার্মিনালে: git commit বা git stash।)", + "error_in_worktree": "সেই ব্রাঞ্চটি ইতিমধ্যে অন্য একটি ওয়ার্কট্রিতে চেক আউট করা আছে।", + "error_exists": "এই নামের একটি ব্রাঞ্চ ইতিমধ্যে আছে।", + "error_in_progress": "একটি মার্জ, রিবেস বা চেরি-পিক চলছে। আগে সেটি শেষ বা বাতিল করো।", + "error_session_busy": "স্যুইচ হয়নি: এই চ্যাট বা অন্য কোনো চ্যাটের একটি এজেন্ট এখনো এই রিপোজিটরিতে কাজ করছে। সেটি শেষ হওয়া পর্যন্ত অপেক্ষা করো বা সেই চ্যাটে থামাও (পাঠাও-এর পাশে ■), তারপর স্যুইচ করো।", + "error_not_found": "সেই ব্রাঞ্চটি আর নেই।", + "error_invalid": "এটি বৈধ ব্রাঞ্চ নাম নয়।", + "error_sandbox": "স্যুইচ করা বন্ধ: এই হোস্ট স্যান্ডবক্সে Git চালাতে পারে না। টার্মিনাল থেকে স্যুইচ করো।", + "error_timeout": "ব্রাঞ্চ বদলাতে Git অনেক বেশি সময় নিয়েছে।", + "switched_to": "{{branch}}-এ স্যুইচ হয়েছে", + "outside_project": "স্যুইচ করা বন্ধ: রিপোজিটরির রুট এই সেশনের প্রজেক্ট ফোল্ডারের বাইরে।", + "switch_blocked_filter": "এই রিপোজিটরির জন্য ব্রাঞ্চ বদলানো আর তৈরি করা বন্ধ। এটি একটি চেকআউট ফিল্টার ব্যবহার করে, অর্থাৎ এমন একটি প্রোগ্রাম যা Git প্রতিবার ব্রাঞ্চ বদলানোর সময় ফাইলগুলোর ওপর চালায়, আর এই প্যানেল সেটি চালাবে না। এর বদলে টার্মিনালে স্যুইচ করো।", + "copy_branch_name": "ব্রাঞ্চের নাম {{branch}} কপি করো", + "copied_branch_name": "ব্রাঞ্চের নাম {{branch}} কপি হয়েছে", + "copy_commit": "কমিট {{sha}} কপি করো", + "copied_commit": "কমিট {{sha}} কপি হয়েছে", + "copy_failed": "“{{name}}” ক্লিপবোর্ডে কপি করা যায়নি।", + "copy_branch_while_running": "ব্রাঞ্চের নাম কপি করো। ব্রাঞ্চ বদলাতে আগে পাঠান-এর পাশের ■ দিয়ে উত্তর থামাও।", + "copy_commit_while_running": "কমিট কপি করো। ব্রাঞ্চ বদলাতে আগে পাঠান-এর পাশের ■ দিয়ে উত্তর থামাও।", + "copied_while_running": "কপি হয়েছে। ব্রাঞ্চ বদলাতে আগে পাঠান-এর পাশের ■ দিয়ে উত্তর থামাও।", + "row_switch_hint": "তোমার পরিবর্তনগুলো থেকে যাবে।", + "dirty_files": "যে ফাইলগুলো বাধা দিচ্ছে: {{files}}।", + "copy_retry": "আবার চেষ্টা করো", + "switch_back_to": "{{branch}}-এ ফিরে যাও", + "name_rule_chars": "শুধু অক্ষর, সংখ্যা, “.”, “_”, “-” আর “/” ব্যবহার করো, আর প্রতিটি অংশ অক্ষর বা সংখ্যা দিয়ে শুরু করো।", + "name_rule_dots": "এতে “..” থাকতে পারবে না, আর কোনো অংশ “.” বা “.lock” দিয়ে শেষ হতে পারবে না।", + "name_rule_reserved": "এই নামটি সংরক্ষিত (HEAD, বা CON-এর মতো কোনো Windows ডিভাইসের নাম)।", + "name_rule_length": "200 বা তার কম অক্ষর ব্যবহার করো।" + }, "workspaceTree": { "status_failed": "রিপোজিটরির অবস্থা পড়া যায়নি।", "row_empty": "খালি ফোল্ডার", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index d6c4985dd5e..383c8ed8f67 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -9371,6 +9371,64 @@ "section_expand_hint": "Änderungen und Verlauf anzeigen", "repos_retry": "Erneut versuchen" }, + "branchSwitcher": { + "switch_branch": "Branch wechseln", + "switch_branch_current": "Branch wechseln (aktuell: {{branch}})", + "search_label": "Branches filtern oder einen neuen benennen", + "search_placeholder": "Branch suchen oder einen neuen benennen…", + "safe_switch_hint": "Deine Änderungen sind sicher: Ein Wechsel überschreibt nie Dateien, die du geändert, aber nicht committet hast. Wäre das so, ändert sich nichts und du siehst, welche Dateien im Weg sind. Nach dem Wechsel zeigt dieser Ordner die Dateien in der Version dieses Branches.", + "branches": "Branches", + "local_branches": "Lokal", + "remote_branches": "Remote", + "remote_row_action": "Zu {{name}} wechseln. Lädt ihn hierher; deine Änderungen bleiben erhalten.", + "ahead_count_one": "{{count}} Commit vor seinem Remote-Branch", + "ahead_count_other": "{{count}} Commits vor seinem Remote-Branch", + "behind_count_one": "{{count}} Commit hinter seinem Remote-Branch", + "behind_count_other": "{{count}} Commits hinter seinem Remote-Branch", + "loading": "Branches werden geladen…", + "load_failed": "Die Branch-Liste konnte nicht geladen werden.", + "no_matches": "Keine passenden Branches", + "invalid_new_name": "Kein gültiger Branch-Name. {{rule}}", + "create_branch": "Neuer Branch „{{name}}“", + "create_branch_from": "Neuer Branch „{{name}}“ von {{base}}", + "create_shortcut_mac": "⌘ Enter drücken oder klicken, um ihn zu erstellen", + "create_shortcut_other": "Strg+Enter drücken oder klicken, um ihn zu erstellen", + "ahead_behind_legend": "↑ vor · ↓ hinter dem Remote-Branch", + "detached": "Losgelöst bei {{sha}}", + "current_branch": "Aktueller Branch", + "truncated": "Nur die {{limit}} neuesten werden aufgeführt", + "not_switchable": "Dieser Name scheint ein Geheimnis zu enthalten. Er wird daher verborgen, und von hier aus kann nicht zu ihm gewechselt werden.", + "error_switch_to": "Wechsel zu {{branch}} nicht möglich", + "error_create": "Branch {{branch}} konnte nicht erstellt werden", + "error_dirty": "Nicht gewechselt: Dieser Branch würde einige deiner nicht committeten Änderungen überschreiben. Committe sie oder bitte den Agenten in diesem Chat, sie zu committen oder zu stashen, und wechsle dann erneut. (Im Terminal: git commit oder git stash.)", + "error_in_worktree": "Dieser Branch ist bereits in einem anderen Worktree ausgecheckt.", + "error_exists": "Ein Branch mit diesem Namen existiert bereits.", + "error_in_progress": "Ein Merge, Rebase oder Cherry-Pick läuft. Schließe ihn zuerst ab oder brich ihn ab.", + "error_session_busy": "Nicht gewechselt: Ein Agent arbeitet noch in diesem Repository, in diesem oder einem anderen Chat. Warte, bis er fertig ist, oder stoppe ihn in diesem Chat (■ neben Senden), und wechsle dann.", + "error_not_found": "Dieser Branch existiert nicht mehr.", + "error_invalid": "Das ist kein gültiger Branch-Name.", + "error_sandbox": "Wechseln ist deaktiviert: Dieser Host kann Git nicht in einer Sandbox ausführen. Wechsle im Terminal.", + "error_timeout": "Git hat zu lange gebraucht, um den Branch zu wechseln.", + "switched_to": "Zu {{branch}} gewechselt", + "outside_project": "Wechseln ist deaktiviert: Das Repository-Stammverzeichnis liegt außerhalb des Projektordners dieser Sitzung.", + "switch_blocked_filter": "Branches wechseln und erstellen ist für dieses Repository deaktiviert. Es nutzt einen Checkout-Filter, ein Programm, das Git bei jedem Branch-Wechsel auf Dateien anwendet, und dieses Panel führt es nicht aus. Wechsle stattdessen in einem Terminal.", + "copy_branch_name": "Branch-Namen {{branch}} kopieren", + "copied_branch_name": "Branch-Name {{branch}} kopiert", + "copy_commit": "Commit {{sha}} kopieren", + "copied_commit": "Commit {{sha}} kopiert", + "copy_failed": "„{{name}}“ konnte nicht in die Zwischenablage kopiert werden.", + "copy_branch_while_running": "Branch-Namen kopieren. Zum Wechseln des Branches zuerst die Antwort mit ■ neben „Senden“ stoppen.", + "copy_commit_while_running": "Commit kopieren. Zum Wechseln des Branches zuerst die Antwort mit ■ neben „Senden“ stoppen.", + "copied_while_running": "Kopiert. Zum Wechseln des Branches zuerst die Antwort mit ■ neben „Senden“ stoppen.", + "row_switch_hint": "Deine Änderungen bleiben erhalten.", + "dirty_files": "Betroffene Dateien: {{files}}.", + "copy_retry": "Erneut versuchen", + "switch_back_to": "Zurück zu {{branch}}", + "name_rule_chars": "Verwende nur Buchstaben, Ziffern, „.“, „_“, „-“ und „/“, und beginne jeden Teil mit einem Buchstaben oder einer Ziffer.", + "name_rule_dots": "Er darf kein „..“ enthalten und kein Teil darf auf „.“ oder „.lock“ enden.", + "name_rule_reserved": "Dieser Name ist reserviert (HEAD oder ein Windows-Gerätename wie CON).", + "name_rule_length": "Verwende höchstens 200 Zeichen." + }, "workspaceTree": { "status_failed": "Der Repository-Status konnte nicht gelesen werden.", "row_empty": "Leerer Ordner", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 4b2a2905755..49b61d5d816 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -5491,6 +5491,64 @@ "ask_agent_changes": "[Àşķ ţĥè àğèñţ àƀøùţ ţĥè çĥàñğèş ················]", "ask_agent_history": "[Àşķ ţĥè àğèñţ àƀøùţ ţĥè ĥìşţøŕý ················]" }, + "branchSwitcher": { + "switch_branch": "[Şẁìţçĥ ƀŕàñçĥ ············]", + "switch_branch_current": "[Şẁìţçĥ ƀŕàñçĥ (çùŕŕèñţ: {{branch}}) ··················]", + "search_label": "[Ƒìĺţèŕ ƀŕàñçĥèş øŕ ñàɱè à ñèẁ øñè ·················]", + "search_placeholder": "[Ƒìñð à ƀŕàñçĥ øŕ ñàɱè à ñèẁ øñè… ················]", + "safe_switch_hint": "[Ýøùŕ çĥàñğèş àŕè şàƒè: şẁìţçĥìñğ ñèṽèŕ øṽèŕẁŕìţèş ƒìĺèş ýøù'ṽè çĥàñğèð ƀùţ ñøţ çøɱɱìţţèð. ̃ ìţ ẁøùĺð, ñøţĥìñğ çĥàñğèş àñð ýøù'ŕè ţøĺð ẁĥìçĥ ƒìĺèş àŕè ìñ ţĥè ẁàý. Àƒţèŕ à şẁìţçĥ, ţĥìş ƒøĺðèŕ şĥøẁş ţĥàţ ƀŕàñçĥ'ş ṽèŕşìøñ øƒ ìţş ƒìĺèş. ······································································]", + "branches": "[Ɓŕàñçĥèş ············]", + "local_branches": "[Ĺøçàĺ ········]", + "remote_branches": "[Ŕèɱøţè ·········]", + "remote_row_action": "[Şẁìţçĥ ţø {{name}}. Ðøẁñĺøàðş ìţ ĥèŕè; ýøùŕ çĥàñğèş àŕè ķèþţ. ···················]", + "ahead_count_one": "[{{count}} çøɱɱìţ àĥèàð øƒ ìţş ŕèɱøţè ƀŕàñçĥ ·················]", + "ahead_count_other": "[{{count}} çøɱɱìţş àĥèàð øƒ ìţş ŕèɱøţè ƀŕàñçĥ ··················]", + "behind_count_one": "[{{count}} çøɱɱìţ ƀèĥìñð ìţş ŕèɱøţè ƀŕàñçĥ ················]", + "behind_count_other": "[{{count}} çøɱɱìţş ƀèĥìñð ìţş ŕèɱøţè ƀŕàñçĥ ·················]", + "loading": "[Ĺøàðìñğ ƀŕàñçĥèş… ···············]", + "load_failed": "[Çøùĺðñ'ţ ĺøàð ţĥè ƀŕàñçĥ ĺìşţ. ·····················]", + "no_matches": "[Ñø ɱàţçĥìñğ ƀŕàñçĥèş ··················]", + "invalid_new_name": "[Ñøţ à ṽàĺìð ƀŕàñçĥ ñàɱè. {{rule}} ··················]", + "create_branch": "[Ñèẁ ƀŕàñçĥ “{{name}}” ············]", + "create_branch_from": "[Ñèẁ ƀŕàñçĥ “{{name}}” ƒŕøɱ {{base}} ·················]", + "create_shortcut_mac": "[Þŕèşş ⌘ Èñţèŕ øŕ çĺìçķ ţø çŕèàţè ìţ ··················]", + "create_shortcut_other": "[Þŕèşş Çţŕĺ+Èñţèŕ øŕ çĺìçķ ţø çŕèàţè ìţ ···················]", + "ahead_behind_legend": "[↑ àĥèàð · ↓ ƀèĥìñð ìţş ŕèɱøţè ƀŕàñçĥ ··················]", + "detached": "[Ðèţàçĥèð àţ {{sha}} ···········]", + "current_branch": "[Çùŕŕèñţ ƀŕàñçĥ ·············]", + "truncated": "[Øñĺý ţĥè {{limit}} ɱøşţ ŕèçèñţ àŕè ĺìşţèð ················]", + "not_switchable": "[Ţĥìş ñàɱè ĺøøķş ĺìķè ìţ çøñţàìñş à şèçŕèţ, şø ìţ'ş ĥìððèñ àñð çàñ'ţ ƀè şẁìţçĥèð ţø ĥèŕè. ··························]", + "error_switch_to": "[Çøùĺðñ'ţ şẁìţçĥ ţø {{branch}} ·················]", + "error_create": "[Çøùĺðñ'ţ çŕèàţè ƀŕàñçĥ {{branch}} ················]", + "error_dirty": "[Ñøţ şẁìţçĥèð: ţĥìş ƀŕàñçĥ ẁøùĺð øṽèŕẁŕìţè şøɱè øƒ ýøùŕ ùñçøɱɱìţţèð çĥàñğèş. Çøɱɱìţ ţĥèɱ, øŕ àşķ ţĥè àğèñţ ìñ ţĥìş çĥàţ ţø çøɱɱìţ øŕ şţàşĥ ţĥèɱ, ţĥèñ şẁìţçĥ àğàìñ. (Ìñ à ţèŕɱìñàĺ: ğìţ çøɱɱìţ øŕ ğìţ şţàşĥ.) ·····························································]", + "error_in_worktree": "[Ţĥàţ ƀŕàñçĥ ìş àĺŕèàðý çĥèçķèð øùţ ìñ àñøţĥèŕ ẁøŕķţŕèè. ···················]", + "error_exists": "[À ƀŕàñçĥ ẁìţĥ ţĥàţ ñàɱè àĺŕèàðý èẋìşţş. ····················]", + "error_in_progress": "[À ɱèŕğè, ŕèƀàşè øŕ çĥèŕŕý-þìçķ ìş ìñ þŕøğŕèşş. Ƒìñìşĥ øŕ àƀøŕţ ìţ ƒìŕşţ. ······················]", + "error_session_busy": "[Ñøţ şẁìţçĥèð: àñ àğèñţ ìş şţìĺĺ ẁøŕķìñğ ìñ ţĥìş ŕèþøşìţøŕý, ìñ ţĥìş çĥàţ øŕ àñøţĥèŕ øñè. Ẁàìţ ƒøŕ ìţ ţø ƒìñìşĥ, øŕ şţøþ ìţ ìñ ţĥàţ çĥàţ (■ ñèẋţ ţø Şèñð), ţĥèñ şẁìţçĥ. ··················································]", + "error_not_found": "[Ţĥàţ ƀŕàñçĥ ñø ĺøñğèŕ èẋìşţş. ····················]", + "error_invalid": "[Ţĥàţ ìşñ'ţ à ṽàĺìð ƀŕàñçĥ ñàɱè. ················]", + "error_sandbox": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥìş ĥøşţ çàñ'ţ ŕùñ Ğìţ ìñ à şàñðƀøẋ. Şẁìţçĥ ƒŕøɱ à ţèŕɱìñàĺ. ························]", + "error_timeout": "[Ğìţ ţøøķ ţøø ĺøñğ ţø şẁìţçĥ ƀŕàñçĥèş. ···················]", + "switched_to": "[Şẁìţçĥèð ţø {{branch}} ···········]", + "outside_project": "[Şẁìţçĥìñğ ìş øƒƒ: ţĥè ŕèþøşìţøŕý ŕøøţ ìş øùţşìðè ţĥìş şèşşìøñ'ş þŕøĵèçţ ƒøĺðèŕ. ························]", + "switch_blocked_filter": "[Şẁìţçĥìñğ àñð çŕèàţìñğ ƀŕàñçĥèş àŕè øƒƒ ƒøŕ ţĥìş ŕèþøşìţøŕý. Ìţ ùşèş à çĥèçķøùţ ƒìĺţèŕ, à þŕøğŕàɱ Ğìţ ŕùñş øñ ƒìĺèş ẁĥèñèṽèŕ ìţ şẁìţçĥèş ƀŕàñçĥèş, àñð ţĥìş þàñèĺ ẁøñ'ţ ŕùñ ìţ. Şẁìţçĥ ìñ à ţèŕɱìñàĺ ìñşţèàð. ······························································]", + "copy_branch_name": "[Çøþý ƀŕàñçĥ ñàɱè {{branch}} ···············]", + "copied_branch_name": "[Çøþìèð ƀŕàñçĥ ñàɱè {{branch}} ·················]", + "copy_commit": "[Çøþý çøɱɱìţ {{sha}} ···········]", + "copied_commit": "[Çøþìèð çøɱɱìţ {{sha}} ·············]", + "copy_failed": "[Çøùĺðñ'ţ çøþý “{{name}}” ţø ţĥè çĺìþƀøàŕð. ·················]", + "copy_branch_while_running": "[Çøþý ƀŕàñçĥ ñàɱè. Ţø şẁìţçĥ ƀŕàñçĥ, ƒìŕşţ şţøþ ţĥè ŕèşþøñşè ẁìţĥ ■ ñèẋţ ţø Şèñð. ························]", + "copy_commit_while_running": "[Çøþý çøɱɱìţ. Ţø şẁìţçĥ ƀŕàñçĥ, ƒìŕşţ şţøþ ţĥè ŕèşþøñşè ẁìţĥ ■ ñèẋţ ţø Şèñð. ·······················]", + "copied_while_running": "[Çøþìèð. Ţø şẁìţçĥ ƀŕàñçĥ, ƒìŕşţ şţøþ ţĥè ŕèşþøñşè ẁìţĥ ■ ñèẋţ ţø Şèñð. ·························]", + "row_switch_hint": "[Ýøùŕ çĥàñğèş àŕè ķèþţ. ···············]", + "dirty_files": "[Ƒìĺèş ìñ ţĥè ẁàý: {{files}}. ·················]", + "copy_retry": "[Ţŕý àğàìñ ··············]", + "switch_back_to": "[Şẁìţçĥ ƀàçķ ţø {{branch}} ··············]", + "name_rule_chars": "[Ùşè øñĺý ĺèţţèŕş, ñùɱƀèŕş, “.”, “_”, “-” àñð “/”, àñð şţàŕţ èàçĥ þàŕţ ẁìţĥ à ĺèţţèŕ øŕ ñùɱƀèŕ. ····························]", + "name_rule_dots": "[Ìţ çàñ'ţ çøñţàìñ “..” øŕ èñð à þàŕţ ẁìţĥ “.” øŕ “.ĺøçķ”. ····················]", + "name_rule_reserved": "[Ţĥàţ ñàɱè ìş ŕèşèŕṽèð (ĤÈÀÐ, øŕ à Ẁìñðøẁş ðèṽìçè ñàɱè şùçĥ àş ÇØÑ). ·······················]", + "name_rule_length": "[Ùşè 200 çĥàŕàçţèŕş øŕ ƒèẁèŕ. ····················]" + }, "agentImportFlow": { "back": "[Ɓàçķ ······]", "bring_your_crew_with_you": "[Ɓŕìñğ ýøùŕ çŕèẁ ẁìţĥ ýøù. ··················]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index c4512479204..e88eabe663a 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -4210,6 +4210,64 @@ "repos_retry": "Retry", "source_project_help": "The repository this chat was started in. The others are repositories the agent worked in." }, + "branchSwitcher": { + "switch_branch": "Switch branch", + "switch_branch_current": "Switch branch (current: {{branch}})", + "search_label": "Filter branches or name a new one", + "search_placeholder": "Find a branch or name a new one…", + "safe_switch_hint": "Your changes are safe: switching never overwrites files you've changed but not committed. If it would, nothing changes and you're told which files are in the way. After a switch, this folder shows that branch's version of its files.", + "branches": "Branches", + "local_branches": "Local", + "remote_branches": "Remote", + "remote_row_action": "Switch to {{name}}. Downloads it here; your changes are kept.", + "ahead_count_one": "{{count}} commit ahead of its remote branch", + "ahead_count_other": "{{count}} commits ahead of its remote branch", + "behind_count_one": "{{count}} commit behind its remote branch", + "behind_count_other": "{{count}} commits behind its remote branch", + "loading": "Loading branches…", + "load_failed": "Couldn't load the branch list.", + "no_matches": "No matching branches", + "invalid_new_name": "Not a valid branch name. {{rule}}", + "create_branch": "New branch “{{name}}”", + "create_branch_from": "New branch “{{name}}” from {{base}}", + "create_shortcut_mac": "Press ⌘ Enter or click to create it", + "create_shortcut_other": "Press Ctrl+Enter or click to create it", + "ahead_behind_legend": "↑ ahead · ↓ behind its remote branch", + "detached": "Detached at {{sha}}", + "current_branch": "Current branch", + "truncated": "Only the {{limit}} most recent are listed", + "not_switchable": "This name looks like it contains a secret, so it's hidden and can't be switched to here.", + "error_switch_to": "Couldn't switch to {{branch}}", + "error_create": "Couldn't create branch {{branch}}", + "error_dirty": "Not switched: this branch would overwrite some of your uncommitted changes. Commit them, or ask the agent in this chat to commit or stash them, then switch again. (In a terminal: git commit or git stash.)", + "error_in_worktree": "That branch is already checked out in another worktree.", + "error_exists": "A branch with that name already exists.", + "error_in_progress": "A merge, rebase or cherry-pick is in progress. Finish or abort it first.", + "error_session_busy": "Not switched: an agent is still working in this repository, in this chat or another one. Wait for it to finish, or stop it in that chat (■ next to Send), then switch.", + "error_not_found": "That branch no longer exists.", + "error_invalid": "That isn't a valid branch name.", + "error_sandbox": "Switching is off: this host can't run Git in a sandbox. Switch from a terminal.", + "error_timeout": "Git took too long to switch branches.", + "switched_to": "Switched to {{branch}}", + "outside_project": "Switching is off: the repository root is outside this session's project folder.", + "switch_blocked_filter": "Switching and creating branches are off for this repository. It uses a checkout filter, a program Git runs on files whenever it switches branches, and this panel won't run it. Switch in a terminal instead.", + "copy_branch_name": "Copy branch name {{branch}}", + "copied_branch_name": "Copied branch name {{branch}}", + "copy_commit": "Copy commit {{sha}}", + "copied_commit": "Copied commit {{sha}}", + "copy_failed": "Couldn't copy “{{name}}” to the clipboard.", + "copy_branch_while_running": "Copy branch name. To switch branch, first stop the response with ■ next to Send.", + "copy_commit_while_running": "Copy commit. To switch branch, first stop the response with ■ next to Send.", + "copied_while_running": "Copied. To switch branch, first stop the response with ■ next to Send.", + "row_switch_hint": "Your changes are kept.", + "dirty_files": "Files in the way: {{files}}.", + "copy_retry": "Try again", + "switch_back_to": "Switch back to {{branch}}", + "name_rule_chars": "Use only letters, numbers, “.”, “_”, “-” and “/”, and start each part with a letter or number.", + "name_rule_dots": "It can't contain “..” or end a part with “.” or “.lock”.", + "name_rule_reserved": "That name is reserved (HEAD, or a Windows device name such as CON).", + "name_rule_length": "Use 200 characters or fewer." + }, "agentImportFlow": { "back": "Back", "bring_your_crew_with_you": "Bring your crew with you.", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index ac9eb6bc517..1b2e0d276d2 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -9512,6 +9512,66 @@ "section_expand_hint": "Mostrar cambios e historial", "repos_retry": "Reintentar" }, + "branchSwitcher": { + "switch_branch": "Cambiar de rama", + "switch_branch_current": "Cambiar de rama (actual: {{branch}})", + "search_label": "Filtra ramas o escribe el nombre de una nueva", + "search_placeholder": "Busca una rama o ponle nombre a una nueva…", + "safe_switch_hint": "Tus cambios están a salvo: cambiar de rama nunca sobrescribe archivos que modificaste y no confirmaste. Si fuera a hacerlo, no cambia nada y se te indica qué archivos lo impiden. Tras el cambio, esta carpeta muestra la versión de sus archivos en esa rama.", + "branches": "Ramas", + "local_branches": "Locales", + "remote_branches": "Remotas", + "remote_row_action": "Cambiar a {{name}}. La descarga aquí; tus cambios se conservan.", + "ahead_count_one": "{{count}} commit por delante de su rama remota", + "ahead_count_many": "{{count}} commits por delante de su rama remota", + "ahead_count_other": "{{count}} commits por delante de su rama remota", + "behind_count_one": "{{count}} commit por detrás de su rama remota", + "behind_count_many": "{{count}} commits por detrás de su rama remota", + "behind_count_other": "{{count}} commits por detrás de su rama remota", + "loading": "Cargando ramas…", + "load_failed": "No se pudo cargar la lista de ramas.", + "no_matches": "Ninguna rama coincide", + "invalid_new_name": "No es un nombre de rama válido. {{rule}}", + "create_branch": "Nueva rama «{{name}}»", + "create_branch_from": "Nueva rama «{{name}}» desde {{base}}", + "create_shortcut_mac": "Pulsa ⌘ Enter o haz clic para crearla", + "create_shortcut_other": "Pulsa Ctrl+Enter o haz clic para crearla", + "ahead_behind_legend": "↑ por delante · ↓ por detrás de su rama remota", + "detached": "Desacoplado en {{sha}}", + "current_branch": "Rama actual", + "truncated": "Solo se muestran las {{limit}} más recientes", + "not_switchable": "Este nombre parece contener un secreto, así que está oculto y no se puede cambiar a esta rama desde aquí.", + "error_switch_to": "No se pudo cambiar a {{branch}}", + "error_create": "No se pudo crear la rama {{branch}}", + "error_dirty": "No se cambió: esta rama sobrescribiría algunos de tus cambios sin commit. Haz commit de ellos, o pide al agente en este chat que haga commit o los aparte con stash, y vuelve a cambiar. (En una terminal: git commit o git stash.)", + "error_in_worktree": "Esa rama ya está extraída en otro worktree.", + "error_exists": "Ya existe una rama con ese nombre.", + "error_in_progress": "Hay un merge, rebase o cherry-pick en curso. Termínalo o cancélalo primero.", + "error_session_busy": "No se cambió: un agente sigue trabajando en este repositorio, en este chat o en otro. Espera a que termine o detenlo en ese chat (■ junto a Enviar) y luego cambia.", + "error_not_found": "Esa rama ya no existe.", + "error_invalid": "Ese no es un nombre de rama válido.", + "error_sandbox": "El cambio está desactivado: este host no puede ejecutar Git en un sandbox. Cambia desde una terminal.", + "error_timeout": "Git tardó demasiado en cambiar de rama.", + "switched_to": "Cambiado a {{branch}}", + "outside_project": "El cambio está desactivado: la raíz del repositorio está fuera de la carpeta del proyecto de esta sesión.", + "switch_blocked_filter": "Cambiar y crear ramas está desactivado en este repositorio. Usa un filtro de checkout, un programa que Git ejecuta sobre los archivos cada vez que cambia de rama, y este panel no lo ejecuta. Cambia de rama en una terminal.", + "copy_branch_name": "Copiar el nombre de la rama {{branch}}", + "copied_branch_name": "Se copió el nombre de la rama {{branch}}", + "copy_commit": "Copiar el commit {{sha}}", + "copied_commit": "Se copió el commit {{sha}}", + "copy_failed": "No se pudo copiar «{{name}}» al portapapeles.", + "copy_branch_while_running": "Copiar el nombre de la rama. Para cambiar de rama, detén primero la respuesta con ■ junto a Enviar.", + "copy_commit_while_running": "Copiar el commit. Para cambiar de rama, detén primero la respuesta con ■ junto a Enviar.", + "copied_while_running": "Copiado. Para cambiar de rama, detén primero la respuesta con ■ junto a Enviar.", + "row_switch_hint": "Tus cambios se conservan.", + "dirty_files": "Archivos afectados: {{files}}.", + "copy_retry": "Reintentar", + "switch_back_to": "Volver a {{branch}}", + "name_rule_chars": "Usa solo letras, números, «.», «_», «-» y «/», y empieza cada parte con una letra o un número.", + "name_rule_dots": "No puede contener «..» ni terminar una parte en «.» o «.lock».", + "name_rule_reserved": "Ese nombre está reservado (HEAD, o un nombre de dispositivo de Windows como CON).", + "name_rule_length": "Usa 200 caracteres como máximo." + }, "workspaceTree": { "status_failed": "No se pudo leer el estado del repositorio.", "row_empty": "Carpeta vacía", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index e5d54b1834d..b5959da5af4 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -9512,6 +9512,66 @@ "section_expand_hint": "Afficher les modifications et l’historique", "repos_retry": "Réessayer" }, + "branchSwitcher": { + "switch_branch": "Changer de branche", + "switch_branch_current": "Changer de branche (actuelle : {{branch}})", + "search_label": "Filtrer les branches ou nommer une nouvelle branche", + "search_placeholder": "Cherche une branche ou nomme une nouvelle…", + "safe_switch_hint": "Tes modifications ne risquent rien : un changement de branche n’écrase jamais les fichiers que tu as modifiés sans les commiter. Si c’était le cas, rien ne change et on t’indique quels fichiers bloquent. Après le changement, ce dossier affiche la version de ses fichiers sur cette branche.", + "branches": "Branches", + "local_branches": "Locales", + "remote_branches": "Distantes", + "remote_row_action": "Passer sur {{name}}. La télécharge ici ; tes modifications sont conservées.", + "ahead_count_one": "{{count}} commit d’avance sur sa branche distante", + "ahead_count_many": "{{count}} commits d’avance sur sa branche distante", + "ahead_count_other": "{{count}} commits d’avance sur sa branche distante", + "behind_count_one": "{{count}} commit de retard sur sa branche distante", + "behind_count_many": "{{count}} commits de retard sur sa branche distante", + "behind_count_other": "{{count}} commits de retard sur sa branche distante", + "loading": "Chargement des branches…", + "load_failed": "Impossible de charger la liste des branches.", + "no_matches": "Aucune branche correspondante", + "invalid_new_name": "Nom de branche non valide. {{rule}}", + "create_branch": "Nouvelle branche « {{name}} »", + "create_branch_from": "Nouvelle branche « {{name}} » depuis {{base}}", + "create_shortcut_mac": "Appuie sur ⌘ Entrée ou clique pour la créer", + "create_shortcut_other": "Appuie sur Ctrl+Entrée ou clique pour la créer", + "ahead_behind_legend": "↑ en avance · ↓ en retard sur sa branche distante", + "detached": "Détaché sur {{sha}}", + "current_branch": "Branche actuelle", + "truncated": "Seules les {{limit}} plus récentes sont listées", + "not_switchable": "Ce nom semble contenir un secret : il est donc masqué et tu ne peux pas basculer dessus d’ici.", + "error_switch_to": "Impossible de passer à {{branch}}", + "error_create": "Impossible de créer la branche {{branch}}", + "error_dirty": "Pas de changement : cette branche écraserait certaines de tes modifications non commitées. Commite-les, ou demande à l'agent dans ce chat de les commiter ou de les mettre de côté avec stash, puis change à nouveau de branche. (Dans un terminal : git commit ou git stash.)", + "error_in_worktree": "Cette branche est déjà extraite dans un autre worktree.", + "error_exists": "Une branche portant ce nom existe déjà.", + "error_in_progress": "Un merge, un rebase ou un cherry-pick est en cours. Termine-le ou annule-le d’abord.", + "error_session_busy": "Branche non changée : un agent travaille encore dans ce dépôt, dans ce chat ou un autre. Attends qu’il termine ou arrête-le dans ce chat (■ à côté de Envoyer), puis change de branche.", + "error_not_found": "Cette branche n’existe plus.", + "error_invalid": "Ce n’est pas un nom de branche valide.", + "error_sandbox": "Le changement est désactivé : cet hôte ne peut pas exécuter Git dans un bac à sable. Change de branche depuis un terminal.", + "error_timeout": "Git a mis trop de temps à changer de branche.", + "switched_to": "Passé à {{branch}}", + "outside_project": "Le changement est désactivé : la racine du dépôt est en dehors du dossier de projet de cette session.", + "switch_blocked_filter": "Le changement et la création de branches sont désactivés pour ce dépôt. Il utilise un filtre de checkout, un programme que Git exécute sur les fichiers à chaque changement de branche, et ce panneau ne l’exécutera pas. Change de branche dans un terminal.", + "copy_branch_name": "Copier le nom de la branche {{branch}}", + "copied_branch_name": "Nom de la branche {{branch}} copié", + "copy_commit": "Copier le commit {{sha}}", + "copied_commit": "Commit {{sha}} copié", + "copy_failed": "Impossible de copier « {{name}} » dans le presse-papiers.", + "copy_branch_while_running": "Copier le nom de la branche. Pour changer de branche, arrête d’abord la réponse avec ■ à côté d’Envoyer.", + "copy_commit_while_running": "Copier le commit. Pour changer de branche, arrête d’abord la réponse avec ■ à côté d’Envoyer.", + "copied_while_running": "Copié. Pour changer de branche, arrête d’abord la réponse avec ■ à côté d’Envoyer.", + "row_switch_hint": "Tes modifications sont conservées.", + "dirty_files": "Fichiers concernés : {{files}}.", + "copy_retry": "Réessayer", + "switch_back_to": "Revenir à {{branch}}", + "name_rule_chars": "Utilise seulement des lettres, des chiffres, « . », « _ », « - » et « / », et commence chaque partie par une lettre ou un chiffre.", + "name_rule_dots": "Il ne peut pas contenir « .. » ni finir une partie par « . » ou « .lock ».", + "name_rule_reserved": "Ce nom est réservé (HEAD, ou un nom de périphérique Windows comme CON).", + "name_rule_length": "Utilise 200 caractères au maximum." + }, "workspaceTree": { "status_failed": "Impossible de lire l’état du dépôt.", "row_empty": "Dossier vide", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 000a5609ead..32bf5cd7354 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -9371,6 +9371,64 @@ "section_expand_hint": "बदलाव और इतिहास देखो", "repos_retry": "फिर से कोशिश करो" }, + "branchSwitcher": { + "switch_branch": "ब्रांच बदलो", + "switch_branch_current": "ब्रांच बदलो (वर्तमान: {{branch}})", + "search_label": "ब्रांच फ़िल्टर करो या नई ब्रांच का नाम दो", + "search_placeholder": "ब्रांच ढूँढो या नई का नाम दो…", + "safe_switch_hint": "तुम्हारे बदलाव सुरक्षित हैं: स्विच करने से वे फ़ाइलें कभी ओवरराइट नहीं होतीं जिन्हें तुमने बदला है पर कमिट नहीं किया। अगर ऐसा होता, तो कुछ नहीं बदलेगा और बताया जाएगा कि कौन-सी फ़ाइलें रुकावट हैं। स्विच के बाद यह फ़ोल्डर अपनी फ़ाइलें उस ब्रांच के वर्शन में दिखाता है।", + "branches": "ब्रांच", + "local_branches": "लोकल", + "remote_branches": "रिमोट", + "remote_row_action": "{{name}} पर स्विच करो। यह यहाँ डाउनलोड होगी; तुम्हारे बदलाव बने रहेंगे।", + "ahead_count_one": "अपनी रिमोट ब्रांच से {{count}} कमिट आगे", + "ahead_count_other": "अपनी रिमोट ब्रांच से {{count}} कमिट आगे", + "behind_count_one": "अपनी रिमोट ब्रांच से {{count}} कमिट पीछे", + "behind_count_other": "अपनी रिमोट ब्रांच से {{count}} कमिट पीछे", + "loading": "ब्रांच लोड हो रही हैं…", + "load_failed": "ब्रांच सूची लोड नहीं हो सकी।", + "no_matches": "कोई मेल खाती ब्रांच नहीं", + "invalid_new_name": "मान्य ब्रांच नाम नहीं है। {{rule}}", + "create_branch": "नई ब्रांच “{{name}}”", + "create_branch_from": "{{base}} से नई ब्रांच “{{name}}”", + "create_shortcut_mac": "बनाने के लिए ⌘ Enter दबाओ या क्लिक करो", + "create_shortcut_other": "बनाने के लिए Ctrl+Enter दबाओ या क्लिक करो", + "ahead_behind_legend": "↑ आगे · ↓ रिमोट ब्रांच से पीछे", + "detached": "{{sha}} पर डिटैच्ड", + "current_branch": "वर्तमान ब्रांच", + "truncated": "केवल सबसे हाल की {{limit}} सूचीबद्ध हैं", + "not_switchable": "इस नाम में कोई सीक्रेट लगता है, इसलिए यह छिपाया गया है और यहाँ से इस पर स्विच नहीं किया जा सकता।", + "error_switch_to": "{{branch}} पर स्विच नहीं हो सका", + "error_create": "ब्रांच {{branch}} नहीं बन सकी", + "error_dirty": "ब्रांच नहीं बदली: यह ब्रांच तुम्हारे बिना कमिट किए कुछ बदलावों को ओवरराइट कर देती। उन्हें कमिट करो, या इस चैट में एजेंट से उन्हें कमिट या stash करने को कहो, फिर दोबारा ब्रांच बदलो। (टर्मिनल में: git commit या git stash।)", + "error_in_worktree": "वह ब्रांच पहले से किसी दूसरे वर्कट्री में चेक आउट है।", + "error_exists": "इस नाम की ब्रांच पहले से मौजूद है।", + "error_in_progress": "कोई मर्ज, रीबेस या चेरी-पिक चल रहा है। पहले उसे पूरा या रद्द करो।", + "error_session_busy": "स्विच नहीं हुआ: इस चैट या किसी दूसरी चैट का एक एजेंट अभी भी इस रिपॉज़िटरी में काम कर रहा है। उसके खत्म होने तक रुको या उस चैट में उसे रोको (भेजें के पास ■), फिर स्विच करो।", + "error_not_found": "वह ब्रांच अब मौजूद नहीं है।", + "error_invalid": "यह मान्य ब्रांच नाम नहीं है।", + "error_sandbox": "स्विच करना बंद है: यह होस्ट Git को सैंडबॉक्स में नहीं चला सकता। टर्मिनल से स्विच करो।", + "error_timeout": "Git को ब्रांच बदलने में बहुत समय लगा।", + "switched_to": "{{branch}} पर स्विच हो गया", + "outside_project": "स्विच करना बंद है: रिपॉज़िटरी रूट इस सेशन के प्रोजेक्ट फ़ोल्डर के बाहर है।", + "switch_blocked_filter": "इस रिपॉज़िटरी के लिए ब्रांच बदलना और बनाना बंद है। यह एक चेकआउट फ़िल्टर इस्तेमाल करती है, यानी एक प्रोग्राम जिसे Git हर बार ब्रांच बदलते समय फ़ाइलों पर चलाता है, और यह पैनल उसे नहीं चलाएगा। इसकी जगह टर्मिनल में स्विच करो।", + "copy_branch_name": "ब्रांच का नाम {{branch}} कॉपी करो", + "copied_branch_name": "ब्रांच का नाम {{branch}} कॉपी हो गया", + "copy_commit": "कमिट {{sha}} कॉपी करो", + "copied_commit": "कमिट {{sha}} कॉपी हो गया", + "copy_failed": "“{{name}}” को क्लिपबोर्ड पर कॉपी नहीं किया जा सका।", + "copy_branch_while_running": "ब्रांच का नाम कॉपी करो। ब्रांच बदलने के लिए पहले भेजें के पास वाले ■ से जवाब रोको।", + "copy_commit_while_running": "कमिट कॉपी करो। ब्रांच बदलने के लिए पहले भेजें के पास वाले ■ से जवाब रोको।", + "copied_while_running": "कॉपी हो गया। ब्रांच बदलने के लिए पहले भेजें के पास वाले ■ से जवाब रोको।", + "row_switch_hint": "तुम्हारे बदलाव बने रहेंगे।", + "dirty_files": "रुकावट वाली फ़ाइलें: {{files}}।", + "copy_retry": "फिर से कोशिश करो", + "switch_back_to": "{{branch}} पर वापस जाओ", + "name_rule_chars": "सिर्फ़ अक्षर, अंक, “.”, “_”, “-” और “/” इस्तेमाल करो, और हर हिस्से की शुरुआत अक्षर या अंक से करो।", + "name_rule_dots": "इसमें “..” नहीं हो सकता, और कोई हिस्सा “.” या “.lock” पर खत्म नहीं हो सकता।", + "name_rule_reserved": "यह नाम आरक्षित है (HEAD, या CON जैसा कोई Windows डिवाइस नाम)।", + "name_rule_length": "200 या उससे कम अक्षर इस्तेमाल करो।" + }, "workspaceTree": { "status_failed": "रिपॉज़िटरी की स्थिति पढ़ी नहीं जा सकी।", "row_empty": "खाली फ़ोल्डर", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index 1d5e04dee0c..13800de82ec 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -9512,6 +9512,66 @@ "section_expand_hint": "Mostra modifiche e cronologia", "repos_retry": "Riprova" }, + "branchSwitcher": { + "switch_branch": "Cambia branch", + "switch_branch_current": "Cambia branch (attuale: {{branch}})", + "search_label": "Filtra i branch o dai un nome a uno nuovo", + "search_placeholder": "Cerca un branch o dai un nome a uno nuovo…", + "safe_switch_hint": "Le tue modifiche sono al sicuro: cambiare branch non sovrascrive mai file che hai modificato ma non committato. Se dovesse farlo, non cambia nulla e ti viene detto quali file sono d’intralcio. Dopo il cambio, questa cartella mostra la versione dei suoi file in quel branch.", + "branches": "Branch", + "local_branches": "Locali", + "remote_branches": "Remoti", + "remote_row_action": "Passa a {{name}}. Lo scarica qui; le tue modifiche vengono conservate.", + "ahead_count_one": "{{count}} commit avanti rispetto al suo branch remoto", + "ahead_count_many": "{{count}} commit avanti rispetto al suo branch remoto", + "ahead_count_other": "{{count}} commit avanti rispetto al suo branch remoto", + "behind_count_one": "{{count}} commit indietro rispetto al suo branch remoto", + "behind_count_many": "{{count}} commit indietro rispetto al suo branch remoto", + "behind_count_other": "{{count}} commit indietro rispetto al suo branch remoto", + "loading": "Caricamento dei branch…", + "load_failed": "Impossibile caricare l’elenco dei branch.", + "no_matches": "Nessun branch corrispondente", + "invalid_new_name": "Nome di branch non valido. {{rule}}", + "create_branch": "Nuovo branch «{{name}}»", + "create_branch_from": "Nuovo branch «{{name}}» da {{base}}", + "create_shortcut_mac": "Premi ⌘ Invio o fai clic per crearlo", + "create_shortcut_other": "Premi Ctrl+Invio o fai clic per crearlo", + "ahead_behind_legend": "↑ avanti · ↓ indietro rispetto al suo branch remoto", + "detached": "Scollegato su {{sha}}", + "current_branch": "Branch attuale", + "truncated": "Sono elencati solo i {{limit}} più recenti", + "not_switchable": "Questo nome sembra contenere un segreto, quindi è nascosto e non puoi passare a questo branch da qui.", + "error_switch_to": "Impossibile passare a {{branch}}", + "error_create": "Impossibile creare il branch {{branch}}", + "error_dirty": "Non cambiato: questo branch sovrascriverebbe alcune delle tue modifiche non committate. Esegui il commit, oppure chiedi all'agente in questa chat di farne il commit o di metterle da parte con stash, poi cambia di nuovo. (In un terminale: git commit o git stash.)", + "error_in_worktree": "Quel branch è già estratto in un altro worktree.", + "error_exists": "Esiste già un branch con quel nome.", + "error_in_progress": "È in corso un merge, un rebase o un cherry-pick. Completalo o annullalo prima.", + "error_session_busy": "Branch non cambiato: un agente sta ancora lavorando in questo repository, in questa chat o in un’altra. Aspetta che finisca o fermalo in quella chat (■ accanto a Invia), poi cambia.", + "error_not_found": "Quel branch non esiste più.", + "error_invalid": "Non è un nome di branch valido.", + "error_sandbox": "Il cambio è disattivato: questo host non può eseguire Git in una sandbox. Cambia branch dal terminale.", + "error_timeout": "Git ha impiegato troppo tempo per cambiare branch.", + "switched_to": "Passato a {{branch}}", + "outside_project": "Il cambio è disattivato: la radice del repository è fuori dalla cartella del progetto di questa sessione.", + "switch_blocked_filter": "Il cambio e la creazione di branch sono disattivati per questo repository. Usa un filtro di checkout, un programma che Git esegue sui file ogni volta che cambia branch, e questo pannello non lo esegue. Cambia branch in un terminale.", + "copy_branch_name": "Copia il nome del branch {{branch}}", + "copied_branch_name": "Nome del branch {{branch}} copiato", + "copy_commit": "Copia il commit {{sha}}", + "copied_commit": "Commit {{sha}} copiato", + "copy_failed": "Impossibile copiare «{{name}}» negli appunti.", + "copy_branch_while_running": "Copia il nome del branch. Per cambiare branch, ferma prima la risposta con ■ accanto a Invia.", + "copy_commit_while_running": "Copia il commit. Per cambiare branch, ferma prima la risposta con ■ accanto a Invia.", + "copied_while_running": "Copiato. Per cambiare branch, ferma prima la risposta con ■ accanto a Invia.", + "row_switch_hint": "Le tue modifiche vengono conservate.", + "dirty_files": "File interessati: {{files}}.", + "copy_retry": "Riprova", + "switch_back_to": "Torna a {{branch}}", + "name_rule_chars": "Usa solo lettere, numeri, «.», «_», «-» e «/», e inizia ogni parte con una lettera o un numero.", + "name_rule_dots": "Non può contenere «..» né terminare una parte con «.» o «.lock».", + "name_rule_reserved": "Questo nome è riservato (HEAD, o un nome di dispositivo Windows come CON).", + "name_rule_length": "Usa al massimo 200 caratteri." + }, "workspaceTree": { "status_failed": "Impossibile leggere lo stato del repository.", "row_empty": "Cartella vuota", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 4cb8e5b19ce..6f3732866ac 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -9230,6 +9230,62 @@ "section_expand_hint": "変更と履歴を表示", "repos_retry": "再試行" }, + "branchSwitcher": { + "switch_branch": "ブランチを切り替え", + "switch_branch_current": "ブランチを切り替え(現在: {{branch}})", + "search_label": "ブランチを絞り込むか、新しいブランチ名を入力", + "search_placeholder": "ブランチを検索、または新しい名前を入力…", + "safe_switch_hint": "変更は安全です: 切り替えによって、変更してまだコミットしていないファイルが上書きされることはありません。上書きされる場合は何も変わらず、どのファイルが妨げているかが表示されます。切り替え後、このフォルダーのファイルはそのブランチの内容になります。", + "branches": "ブランチ", + "local_branches": "ローカル", + "remote_branches": "リモート", + "remote_row_action": "{{name}} に切り替え。ここにダウンロードされ、変更は保持されます。", + "ahead_count_other": "リモートブランチより {{count}} コミット進んでいます", + "behind_count_other": "リモートブランチより {{count}} コミット遅れています", + "loading": "ブランチを読み込み中…", + "load_failed": "ブランチ一覧を読み込めませんでした。", + "no_matches": "一致するブランチはありません", + "invalid_new_name": "有効なブランチ名ではありません。{{rule}}", + "create_branch": "新しいブランチ「{{name}}」", + "create_branch_from": "{{base}} から新しいブランチ「{{name}}」", + "create_shortcut_mac": "⌘ Enter またはクリックで作成", + "create_shortcut_other": "Ctrl+Enter またはクリックで作成", + "ahead_behind_legend": "↑ 先行 · ↓ 遅れ(リモートブランチ比)", + "detached": "{{sha}} でデタッチ", + "current_branch": "現在のブランチ", + "truncated": "最新の {{limit}} 件のみ表示しています", + "not_switchable": "この名前にはシークレットが含まれているようなので非表示にしており、ここから切り替えることはできません。", + "error_switch_to": "{{branch}} に切り替えられませんでした", + "error_create": "ブランチ {{branch}} を作成できませんでした", + "error_dirty": "切り替えていません: このブランチに切り替えると、コミットしていない変更の一部が上書きされます。変更をコミットするか、このチャットでエージェントにコミットまたは stash を頼んでから、もう一度切り替えてください。(ターミナルでは git commit または git stash)", + "error_in_worktree": "そのブランチは別のワークツリーですでにチェックアウトされています。", + "error_exists": "同じ名前のブランチがすでに存在します。", + "error_in_progress": "マージ、リベース、またはチェリーピックが進行中です。先に完了するか中止してください。", + "error_session_busy": "切り替えていません: このリポジトリで、このチャットか別のチャットのエージェントがまだ作業中です。終わるまで待つか、そのチャットで停止してから(送信の横の ■)切り替えてください。", + "error_not_found": "そのブランチはもう存在しません。", + "error_invalid": "有効なブランチ名ではありません。", + "error_sandbox": "切り替えは無効です: このホストでは Git をサンドボックスで実行できません。ターミナルから切り替えてください。", + "error_timeout": "Git のブランチ切り替えに時間がかかりすぎました。", + "switched_to": "{{branch}} に切り替えました", + "outside_project": "切り替えは無効です: リポジトリのルートがこのセッションのプロジェクトフォルダーの外にあります。", + "switch_blocked_filter": "このリポジトリではブランチの切り替えと作成がオフになっています。チェックアウトフィルター(ブランチを切り替えるたびに Git がファイルに対して実行するプログラム)を使っていて、このパネルはそれを実行しません。代わりにターミナルで切り替えてください。", + "copy_branch_name": "ブランチ名 {{branch}} をコピー", + "copied_branch_name": "ブランチ名 {{branch}} をコピーしました", + "copy_commit": "コミット {{sha}} をコピー", + "copied_commit": "コミット {{sha}} をコピーしました", + "copy_failed": "「{{name}}」をクリップボードにコピーできませんでした。", + "copy_branch_while_running": "ブランチ名をコピーします。ブランチを切り替えるには、まず「送信」の横の ■ で応答を停止してください。", + "copy_commit_while_running": "コミットをコピーします。ブランチを切り替えるには、まず「送信」の横の ■ で応答を停止してください。", + "copied_while_running": "コピーしました。ブランチを切り替えるには、まず「送信」の横の ■ で応答を停止してください。", + "row_switch_hint": "変更は保持されます。", + "dirty_files": "対象のファイル: {{files}}。", + "copy_retry": "再試行", + "switch_back_to": "{{branch}} に戻す", + "name_rule_chars": "使えるのは英字、数字、「.」「_」「-」「/」だけで、各部分は英字か数字で始めてください。", + "name_rule_dots": "「..」を含めることや、部分の末尾を「.」や「.lock」にすることはできません。", + "name_rule_reserved": "この名前は予約されています(HEAD や、CON などの Windows のデバイス名)。", + "name_rule_length": "200 文字以内にしてください。" + }, "workspaceTree": { "status_failed": "リポジトリの状態を読み取れませんでした。", "row_empty": "空のフォルダー", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 42fae026980..24714118ded 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -9230,6 +9230,62 @@ "section_expand_hint": "변경 사항 및 기록 보기", "repos_retry": "다시 시도" }, + "branchSwitcher": { + "switch_branch": "브랜치 전환", + "switch_branch_current": "브랜치 전환 (현재: {{branch}})", + "search_label": "브랜치를 필터링하거나 새 브랜치 이름 입력", + "search_placeholder": "브랜치를 찾거나 새 이름 입력…", + "safe_switch_hint": "변경 사항은 안전합니다: 전환해도 변경했지만 커밋하지 않은 파일은 절대 덮어쓰지 않습니다. 덮어쓰게 되면 아무것도 바뀌지 않고, 어떤 파일이 걸리는지 알려 줍니다. 전환 후 이 폴더의 파일은 그 브랜치의 버전으로 보입니다.", + "branches": "브랜치", + "local_branches": "로컬", + "remote_branches": "원격", + "remote_row_action": "{{name}}(으)로 전환. 여기로 내려받으며 변경 사항은 유지됩니다.", + "ahead_count_other": "원격 브랜치보다 커밋 {{count}}개 앞섬", + "behind_count_other": "원격 브랜치보다 커밋 {{count}}개 뒤처짐", + "loading": "브랜치를 불러오는 중…", + "load_failed": "브랜치 목록을 불러오지 못했습니다.", + "no_matches": "일치하는 브랜치가 없습니다", + "invalid_new_name": "유효한 브랜치 이름이 아닙니다. {{rule}}", + "create_branch": "새 브랜치 ‘{{name}}’", + "create_branch_from": "{{base}}에서 새 브랜치 ‘{{name}}’", + "create_shortcut_mac": "⌘ Enter를 누르거나 클릭해 만들기", + "create_shortcut_other": "Ctrl+Enter를 누르거나 클릭해 만들기", + "ahead_behind_legend": "↑ 앞섬 · ↓ 뒤처짐 (원격 브랜치 기준)", + "detached": "{{sha}}에서 분리됨", + "current_branch": "현재 브랜치", + "truncated": "최근 {{limit}}개만 표시됩니다", + "not_switchable": "이 이름에 비밀 정보가 포함된 것으로 보여 숨겨져 있으며, 여기서는 이 브랜치로 전환할 수 없습니다.", + "error_switch_to": "{{branch}}(으)로 전환하지 못했습니다", + "error_create": "브랜치 {{branch}}을(를) 만들지 못했습니다", + "error_dirty": "전환하지 않음: 이 브랜치로 전환하면 커밋하지 않은 변경 사항 일부를 덮어쓰게 됩니다. 변경 사항을 커밋하거나, 이 채팅에서 에이전트에게 커밋 또는 stash를 요청한 뒤 다시 전환하세요. (터미널에서: git commit 또는 git stash)", + "error_in_worktree": "해당 브랜치는 이미 다른 워크트리에서 체크아웃되어 있습니다.", + "error_exists": "같은 이름의 브랜치가 이미 있습니다.", + "error_in_progress": "병합, 리베이스 또는 체리픽이 진행 중입니다. 먼저 완료하거나 중단하세요.", + "error_session_busy": "전환하지 않았습니다: 이 채팅이나 다른 채팅의 에이전트가 이 저장소에서 아직 작업 중입니다. 끝날 때까지 기다리거나 그 채팅에서 중지한 후(보내기 옆 ■) 전환하세요.", + "error_not_found": "해당 브랜치가 더 이상 없습니다.", + "error_invalid": "유효한 브랜치 이름이 아닙니다.", + "error_sandbox": "전환이 꺼져 있습니다: 이 호스트에서는 Git을 샌드박스에서 실행할 수 없습니다. 터미널에서 전환하세요.", + "error_timeout": "Git이 브랜치를 전환하는 데 너무 오래 걸렸습니다.", + "switched_to": "{{branch}}(으)로 전환했습니다", + "outside_project": "전환이 꺼져 있습니다: 저장소 루트가 이 세션의 프로젝트 폴더 밖에 있습니다.", + "switch_blocked_filter": "이 저장소에서는 브랜치 전환과 생성이 꺼져 있습니다. 체크아웃 필터(브랜치를 전환할 때마다 Git이 파일에 실행하는 프로그램)를 사용하며, 이 패널은 이를 실행하지 않습니다. 대신 터미널에서 전환하세요.", + "copy_branch_name": "브랜치 이름 {{branch}} 복사", + "copied_branch_name": "브랜치 이름 {{branch}}을(를) 복사했습니다", + "copy_commit": "커밋 {{sha}} 복사", + "copied_commit": "커밋 {{sha}}을(를) 복사했습니다", + "copy_failed": "‘{{name}}’을(를) 클립보드에 복사하지 못했습니다.", + "copy_branch_while_running": "브랜치 이름을 복사합니다. 브랜치를 전환하려면 먼저 전송 옆의 ■ 버튼으로 응답을 중지하세요.", + "copy_commit_while_running": "커밋을 복사합니다. 브랜치를 전환하려면 먼저 전송 옆의 ■ 버튼으로 응답을 중지하세요.", + "copied_while_running": "복사했습니다. 브랜치를 전환하려면 먼저 전송 옆의 ■ 버튼으로 응답을 중지하세요.", + "row_switch_hint": "변경 사항이 유지됩니다.", + "dirty_files": "해당 파일: {{files}}.", + "copy_retry": "다시 시도", + "switch_back_to": "{{branch}}(으)로 되돌리기", + "name_rule_chars": "영문자, 숫자, “.”, “_”, “-”, “/”만 사용하고 각 부분은 영문자나 숫자로 시작하세요.", + "name_rule_dots": "“..”를 포함하거나 부분이 “.” 또는 “.lock”으로 끝날 수 없습니다.", + "name_rule_reserved": "예약된 이름입니다(HEAD 또는 CON 같은 Windows 장치 이름).", + "name_rule_length": "200자 이하로 입력하세요." + }, "workspaceTree": { "status_failed": "리포지토리 상태를 읽을 수 없습니다.", "row_empty": "빈 폴더", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 4cc7edd2999..a20818c794f 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -9512,6 +9512,66 @@ "section_expand_hint": "Mostrar alterações e histórico", "repos_retry": "Tentar novamente" }, + "branchSwitcher": { + "switch_branch": "Trocar de branch", + "switch_branch_current": "Trocar de branch (atual: {{branch}})", + "search_label": "Filtre branches ou dê nome a um novo", + "search_placeholder": "Encontre um branch ou dê nome a um novo…", + "safe_switch_hint": "Suas alterações estão seguras: trocar de branch nunca sobrescreve arquivos que você alterou e não commitou. Se fosse acontecer, nada muda e você vê quais arquivos estão no caminho. Depois da troca, esta pasta mostra a versão dos arquivos nesse branch.", + "branches": "Branches", + "local_branches": "Locais", + "remote_branches": "Remotos", + "remote_row_action": "Trocar para {{name}}. Baixa aqui; suas alterações são mantidas.", + "ahead_count_one": "{{count}} commit à frente do seu branch remoto", + "ahead_count_many": "{{count}} commits à frente do seu branch remoto", + "ahead_count_other": "{{count}} commits à frente do seu branch remoto", + "behind_count_one": "{{count}} commit atrás do seu branch remoto", + "behind_count_many": "{{count}} commits atrás do seu branch remoto", + "behind_count_other": "{{count}} commits atrás do seu branch remoto", + "loading": "Carregando branches…", + "load_failed": "Não foi possível carregar a lista de branches.", + "no_matches": "Nenhum branch corresponde", + "invalid_new_name": "Não é um nome de branch válido. {{rule}}", + "create_branch": "Novo branch “{{name}}”", + "create_branch_from": "Novo branch “{{name}}” a partir de {{base}}", + "create_shortcut_mac": "Pressione ⌘ Enter ou clique para criar", + "create_shortcut_other": "Pressione Ctrl+Enter ou clique para criar", + "ahead_behind_legend": "↑ à frente · ↓ atrás do branch remoto", + "detached": "Desanexado em {{sha}}", + "current_branch": "Branch atual", + "truncated": "Apenas os {{limit}} mais recentes são listados", + "not_switchable": "Este nome parece conter um segredo, então fica oculto e não é possível trocar para ele daqui.", + "error_switch_to": "Não foi possível trocar para {{branch}}", + "error_create": "Não foi possível criar o branch {{branch}}", + "error_dirty": "Não trocado: este branch sobrescreveria algumas das suas alterações sem commit. Faça commit delas, ou peça ao agente neste chat para fazer commit ou guardá-las com stash, e depois troque de novo. (Em um terminal: git commit ou git stash.)", + "error_in_worktree": "Esse branch já está em checkout em outro worktree.", + "error_exists": "Já existe um branch com esse nome.", + "error_in_progress": "Há um merge, rebase ou cherry-pick em andamento. Conclua ou cancele primeiro.", + "error_session_busy": "Não trocou: um agente ainda está trabalhando neste repositório, neste chat ou em outro. Aguarde terminar ou pare-o nesse chat (■ ao lado de Enviar) e depois troque.", + "error_not_found": "Esse branch não existe mais.", + "error_invalid": "Esse não é um nome de branch válido.", + "error_sandbox": "A troca está desativada: este host não consegue executar o Git em uma sandbox. Troque pelo terminal.", + "error_timeout": "O Git demorou demais para trocar de branch.", + "switched_to": "Trocado para {{branch}}", + "outside_project": "A troca está desativada: a raiz do repositório está fora da pasta do projeto desta sessão.", + "switch_blocked_filter": "Trocar e criar branches está desativado neste repositório. Ele usa um filtro de checkout, um programa que o Git executa nos arquivos sempre que troca de branch, e este painel não o executa. Troque de branch em um terminal.", + "copy_branch_name": "Copiar o nome do branch {{branch}}", + "copied_branch_name": "Nome do branch {{branch}} copiado", + "copy_commit": "Copiar o commit {{sha}}", + "copied_commit": "Commit {{sha}} copiado", + "copy_failed": "Não foi possível copiar “{{name}}” para a área de transferência.", + "copy_branch_while_running": "Copiar o nome do branch. Para trocar de branch, pare primeiro a resposta com ■ ao lado de Enviar.", + "copy_commit_while_running": "Copiar o commit. Para trocar de branch, pare primeiro a resposta com ■ ao lado de Enviar.", + "copied_while_running": "Copiado. Para trocar de branch, pare primeiro a resposta com ■ ao lado de Enviar.", + "row_switch_hint": "Suas alterações são mantidas.", + "dirty_files": "Arquivos afetados: {{files}}.", + "copy_retry": "Tentar de novo", + "switch_back_to": "Voltar para {{branch}}", + "name_rule_chars": "Use só letras, números, “.”, “_”, “-” e “/”, e comece cada parte com uma letra ou um número.", + "name_rule_dots": "Não pode conter “..” nem terminar uma parte com “.” ou “.lock”.", + "name_rule_reserved": "Esse nome é reservado (HEAD, ou um nome de dispositivo do Windows como CON).", + "name_rule_length": "Use no máximo 200 caracteres." + }, "workspaceTree": { "status_failed": "Não foi possível ler o estado do repositório.", "row_empty": "Pasta vazia", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index f0283c45283..3328988e028 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -9653,6 +9653,68 @@ "section_expand_hint": "Показать изменения и историю", "repos_retry": "Повторить" }, + "branchSwitcher": { + "switch_branch": "Сменить ветку", + "switch_branch_current": "Сменить ветку (текущая: {{branch}})", + "search_label": "Отфильтруй ветки или введи имя новой", + "search_placeholder": "Найди ветку или назови новую…", + "safe_switch_hint": "Твои изменения в безопасности: переключение никогда не перезаписывает файлы, которые ты изменил, но не закоммитил. Если бы это случилось, ничего не меняется, и показано, какие файлы мешают. После переключения эта папка показывает файлы в версии этой ветки.", + "branches": "Ветки", + "local_branches": "Локальные", + "remote_branches": "Удалённые", + "remote_row_action": "Переключиться на {{name}}. Загружает её сюда; твои изменения сохранятся.", + "ahead_count_one": "На {{count}} коммит впереди своей удалённой ветки", + "ahead_count_few": "На {{count}} коммита впереди своей удалённой ветки", + "ahead_count_many": "На {{count}} коммитов впереди своей удалённой ветки", + "ahead_count_other": "На {{count}} коммита впереди своей удалённой ветки", + "behind_count_one": "На {{count}} коммит позади своей удалённой ветки", + "behind_count_few": "На {{count}} коммита позади своей удалённой ветки", + "behind_count_many": "На {{count}} коммитов позади своей удалённой ветки", + "behind_count_other": "На {{count}} коммита позади своей удалённой ветки", + "loading": "Загрузка веток…", + "load_failed": "Не удалось загрузить список веток.", + "no_matches": "Подходящих веток нет", + "invalid_new_name": "Недопустимое имя ветки. {{rule}}", + "create_branch": "Новая ветка «{{name}}»", + "create_branch_from": "Новая ветка «{{name}}» от {{base}}", + "create_shortcut_mac": "Нажми ⌘ Enter или щёлкни, чтобы создать", + "create_shortcut_other": "Нажми Ctrl+Enter или щёлкни, чтобы создать", + "ahead_behind_legend": "↑ впереди · ↓ позади удалённой ветки", + "detached": "Отсоединено на {{sha}}", + "current_branch": "Текущая ветка", + "truncated": "Показаны только последние: {{limit}}", + "not_switchable": "Похоже, это имя содержит секрет, поэтому оно скрыто и переключиться на эту ветку отсюда нельзя.", + "error_switch_to": "Не удалось переключиться на {{branch}}", + "error_create": "Не удалось создать ветку {{branch}}", + "error_dirty": "Не переключено: эта ветка перезаписала бы часть твоих незакоммиченных изменений. Закоммить их или попроси агента в этом чате закоммитить их или убрать в stash, затем переключись снова. (В терминале: git commit или git stash.)", + "error_in_worktree": "Эта ветка уже извлечена в другом рабочем дереве (worktree).", + "error_exists": "Ветка с таким именем уже существует.", + "error_in_progress": "Идёт слияние, перебазирование или cherry-pick. Сначала заверши или отмени его.", + "error_session_busy": "Не переключено: агент ещё работает в этом репозитории, в этом чате или в другом. Дождись его завершения или останови его в том чате (■ рядом с «Отправить»), затем переключись.", + "error_not_found": "Этой ветки больше нет.", + "error_invalid": "Это недопустимое имя ветки.", + "error_sandbox": "Переключение отключено: на этом хосте Git нельзя запустить в песочнице. Переключись в терминале.", + "error_timeout": "Git слишком долго переключал ветку.", + "switched_to": "Переключено на {{branch}}", + "outside_project": "Переключение отключено: корень репозитория находится вне папки проекта этого сеанса.", + "switch_blocked_filter": "Переключение и создание веток отключены для этого репозитория. Он использует фильтр checkout — программу, которую Git запускает для файлов при каждом переключении ветки, и эта панель её не запускает. Переключись в терминале.", + "copy_branch_name": "Скопировать имя ветки {{branch}}", + "copied_branch_name": "Имя ветки {{branch}} скопировано", + "copy_commit": "Скопировать коммит {{sha}}", + "copied_commit": "Коммит {{sha}} скопирован", + "copy_failed": "Не удалось скопировать «{{name}}» в буфер обмена.", + "copy_branch_while_running": "Скопировать имя ветки. Чтобы сменить ветку, сначала останови ответ кнопкой ■ рядом с «Отправить».", + "copy_commit_while_running": "Скопировать коммит. Чтобы сменить ветку, сначала останови ответ кнопкой ■ рядом с «Отправить».", + "copied_while_running": "Скопировано. Чтобы сменить ветку, сначала останови ответ кнопкой ■ рядом с «Отправить».", + "row_switch_hint": "Твои изменения сохранятся.", + "dirty_files": "Мешающие файлы: {{files}}.", + "copy_retry": "Повторить", + "switch_back_to": "Вернуться на {{branch}}", + "name_rule_chars": "Используй только буквы, цифры, «.», «_», «-» и «/», и начинай каждую часть с буквы или цифры.", + "name_rule_dots": "Имя не может содержать «..», а части не могут заканчиваться на «.» или «.lock».", + "name_rule_reserved": "Это имя зарезервировано (HEAD или имя устройства Windows, например CON).", + "name_rule_length": "Используй не больше 200 символов." + }, "workspaceTree": { "status_failed": "Не удалось прочитать состояние репозитория.", "row_empty": "Пустая папка", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index b7d687b74fd..da5a2f5aa01 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -9230,6 +9230,62 @@ "section_expand_hint": "显示更改和历史记录", "repos_retry": "重试" }, + "branchSwitcher": { + "switch_branch": "切换分支", + "switch_branch_current": "切换分支(当前:{{branch}})", + "search_label": "筛选分支或输入新分支名", + "search_placeholder": "查找分支或为新分支命名…", + "safe_switch_hint": "更改不会丢:切换绝不覆盖改过但还没提交的文件。若会覆盖,就什么都不变,并列出挡路的文件。切换后,这个文件夹里显示那个分支上的文件版本。", + "branches": "分支", + "local_branches": "本地", + "remote_branches": "远程", + "remote_row_action": "切换到 {{name}}。会下载到这里;你的更改会保留。", + "ahead_count_other": "领先远程分支 {{count}} 个提交", + "behind_count_other": "落后远程分支 {{count}} 个提交", + "loading": "正在加载分支…", + "load_failed": "无法加载分支列表。", + "no_matches": "没有匹配的分支", + "invalid_new_name": "不是有效的分支名。{{rule}}", + "create_branch": "新分支“{{name}}”", + "create_branch_from": "从 {{base}} 新建分支“{{name}}”", + "create_shortcut_mac": "按 ⌘ Enter 或点击即可创建", + "create_shortcut_other": "按 Ctrl+Enter 或点击即可创建", + "ahead_behind_legend": "↑ 领先 · ↓ 落后于远程分支", + "detached": "分离于 {{sha}}", + "current_branch": "当前分支", + "truncated": "仅列出最近的 {{limit}} 个", + "not_switchable": "此名称看起来包含密钥,因此已隐藏,无法在此切换到它。", + "error_switch_to": "无法切换到 {{branch}}", + "error_create": "无法创建分支 {{branch}}", + "error_dirty": "未切换:这个分支会覆盖你部分未提交的修改。请先提交它们,或在这个聊天中让智能体帮你提交或 stash,然后再切换。(在终端中:git commit 或 git stash)", + "error_in_worktree": "该分支已在另一个工作树中检出。", + "error_exists": "已存在同名分支。", + "error_in_progress": "有合并、变基或拣选(cherry-pick)正在进行。请先完成或中止它。", + "error_session_busy": "未切换:这个对话或另一个对话中的智能体仍在此仓库中工作。请等它结束,或在那个对话中停止它(发送旁的 ■),然后再切换。", + "error_not_found": "该分支已不存在。", + "error_invalid": "这不是有效的分支名。", + "error_sandbox": "切换已关闭:此主机无法在沙盒中运行 Git。请在终端中切换。", + "error_timeout": "Git 切换分支耗时过长。", + "switched_to": "已切换到 {{branch}}", + "outside_project": "切换已关闭:仓库根目录不在此会话的项目文件夹内。", + "switch_blocked_filter": "此仓库已关闭切换和创建分支。它使用了检出过滤器,即 Git 每次切换分支时对文件运行的程序,而此面板不会运行它。请改在终端中切换。", + "copy_branch_name": "复制分支名称 {{branch}}", + "copied_branch_name": "已复制分支名称 {{branch}}", + "copy_commit": "复制提交 {{sha}}", + "copied_commit": "已复制提交 {{sha}}", + "copy_failed": "无法将“{{name}}”复制到剪贴板。", + "copy_branch_while_running": "复制分支名称。要切换分支,请先用“发送”旁的 ■ 停止回复。", + "copy_commit_while_running": "复制提交。要切换分支,请先用“发送”旁的 ■ 停止回复。", + "copied_while_running": "已复制。要切换分支,请先用“发送”旁的 ■ 停止回复。", + "row_switch_hint": "你的更改会保留。", + "dirty_files": "受影响的文件:{{files}}。", + "copy_retry": "重试", + "switch_back_to": "切换回 {{branch}}", + "name_rule_chars": "只能使用字母、数字、“.”“_”“-”和“/”,且每一段都要以字母或数字开头。", + "name_rule_dots": "不能包含“..”,每一段也不能以“.”或“.lock”结尾。", + "name_rule_reserved": "这个名称是保留的(HEAD,或 CON 等 Windows 设备名)。", + "name_rule_length": "请不要超过 200 个字符。" + }, "workspaceTree": { "status_failed": "无法读取仓库状态。", "row_empty": "空文件夹", diff --git a/website/src/i18n/pluralKeys.json b/website/src/i18n/pluralKeys.json index 2817f0b1888..f2f1e4c2895 100644 --- a/website/src/i18n/pluralKeys.json +++ b/website/src/i18n/pluralKeys.json @@ -309,5 +309,7 @@ "pages.mcpManagement.reload_confirm_title", "pages.mcpManagement.approval_title", "pages.mcpManagement.confirm_will_share", - "apps.issueRadar.components.threads.show_earlier" + "apps.issueRadar.components.threads.show_earlier", + "components.branchSwitcher.ahead_count", + "components.branchSwitcher.behind_count" ] diff --git a/website/src/pages/chat/ActivityViewer.tsx b/website/src/pages/chat/ActivityViewer.tsx index 9ef657b2c6f..49dbd84079d 100644 --- a/website/src/pages/chat/ActivityViewer.tsx +++ b/website/src/pages/chat/ActivityViewer.tsx @@ -18,7 +18,7 @@ import IssuePanel from '../../components/IssuePanel' import { PinnedMessagesPanel } from './PinnedMessagesPanel' import type { ChatPin } from '../../api/pins' import { useAppSelector, useAppDispatch } from '../../store' -import { markSubagentApproving, markSubagentApprovalGone, reconcileGoneSubagent, isSpawnApprovalGone, isSpawnApprovalRetired, openActivityToTab, selectSubagent, clearTerminalSubagents, sseSubagentDone } from '../../store/chatSlice' +import { markSubagentApproving, markSubagentApprovalGone, reconcileGoneSubagent, isSpawnApprovalGone, isSpawnApprovalRetired, openActivityToTab, selectSubagent, clearTerminalSubagents, sseSubagentDone, selectComposerBusy } from '../../store/chatSlice' import SegmentedControl from '../../components/SegmentedControl' import { Btn, PanelSectionHeader, ContentSkeleton } from '../../components/ui' import SideChat from './SideChat' @@ -1109,6 +1109,9 @@ export default function ActivityViewer({ subagents, toolLog, open, onToggle, slo // Why they wait, when the gateway said (memory floor, critical posture, a // paused adaptive cap); undefined keeps the concurrency text below. const queuedReason = useAppSelector(s => s.chat.subagentQueuedReason?.[slot]) + // The Git panel's branch switcher is off while this chat's composer is busy, + // the same rule the composer chip follows. + const composerBusy = useAppSelector(s => selectComposerBusy(s, slot || null)) // Render cap: bounds DOM at 60-100 agents; exceptions are always within // the cap thanks to the ordering above. const [showAllSubagents, setShowAllSubagents] = useState(false) @@ -1267,7 +1270,7 @@ export default function ActivityViewer({ subagents, toolLog, open, onToggle, slo {/* Git: every repository this chat works in, the project's first */} {effectiveTab === ('git' as string) && (
- +
)} diff --git a/website/src/test/ApiClient.refactor.surface.test.ts b/website/src/test/ApiClient.refactor.surface.test.ts index d865053f820..2f44c451c14 100644 --- a/website/src/test/ApiClient.refactor.surface.test.ts +++ b/website/src/test/ApiClient.refactor.surface.test.ts @@ -76,6 +76,7 @@ const API_KEY_ORDER = [ 'createWorktree', 'recentProjects', 'favoriteProjects', 'addFavoriteProject', 'removeFavoriteProject', 'projectAwsProfile', 'setProjectAwsProfile', 'browseDirs', 'browseDrives', 'browseFiles', 'projectGit', 'projectGitStatus', 'projectGitLog', 'projectGitRepos', + 'projectGitBranches', 'projectGitSwitch', 'projectTree', 'workspaces', 'createWorkspace', 'updateWorkspace', 'deleteWorkspace', 'crons', 'createCron', 'deleteCron', 'batchDeleteCron', 'updateCron', 'runCron', 'cronSecretsGrant', @@ -223,6 +224,7 @@ const NO_SESSION_KEY = [ 'models', 'chatSlotSelectionCapabilities', 'effortLevels', 'slashCommands', 'chatSlotAutocompact', 'chatSlotTurnTimeout', 'chatSlotsFollowDefaultCount', 'recentProjects', 'favoriteProjects', 'browseDirs', 'browseDrives', 'browseFiles', 'projectGit', 'projectGitStatus', 'projectGitLog', 'projectGitRepos', + 'projectGitBranches', 'projectTree', 'workspaces', 'crons', 'updateCron', 'cronFolders', 'updateCronFolder', 'lessons', 'hooks', 'kiroHooks', 'webhooks', 'updateWebhookToken', 'prompts', diff --git a/website/src/test/BranchSwitcher.test.tsx b/website/src/test/BranchSwitcher.test.tsx new file mode 100644 index 00000000000..338f5a6e52e --- /dev/null +++ b/website/src/test/BranchSwitcher.test.tsx @@ -0,0 +1,839 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { i18next, initI18n } from '../i18n/all' +import type { GitBranchList, GitBranchRow } from '../api/client/files' + +const H = vi.hoisted(() => ({ + api: { + projectGitBranches: vi.fn(), + projectGitSwitch: vi.fn(), + }, +})) + +vi.mock('../api/client', () => ({ api: H.api })) + +import BranchSwitcher, { isValidNewBranchName, newBranchNameProblem, notePlacement } from '../components/BranchSwitcher' +import { LocalOverlayRoot } from '../components/overlayRoot' + +const PROJECT = '/workspace/project' + +function row(name: string, extra: Partial = {}): GitBranchRow { + return { + name, + date: '2026-10-01T00:00:00Z', + author: 'Ada', + subject: `work on ${name}`, + switchable: true, + ...extra, + } +} + +const LIST: GitBranchList = { + repo: true, + current: 'main', + local: [row('main', { current: true }), row('feature/login', { ahead: 2 }), row('secret', { switchable: false })], + remote: [row('origin/review-fix')], +} + +function mount(props: { switchBlocked?: boolean; detached?: boolean; branch?: string } = {}) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) + const invalidate = vi.spyOn(qc, 'invalidateQueries') + render( + + + , + ) + return { invalidate } +} + +async function openPicker() { + fireEvent.click(screen.getByTestId('branch-switcher-trigger')) + await screen.findByText('feature/login') +} + +function apiError(code: string, extra: Record = {}) { + return Object.assign(new Error(code), { body: JSON.stringify({ error: 'x', code, ...extra }) }) +} + +beforeEach(async () => { + await initI18n() + await i18next.changeLanguage('en') + H.api.projectGitBranches.mockReset().mockResolvedValue(LIST) + H.api.projectGitSwitch.mockReset().mockResolvedValue({ ok: true, branch: 'feature/login' }) +}) + +describe('BranchSwitcher', () => { + it('narrows to the viewport when 360px does not fit beside the gutters', async () => { + const original = window.innerWidth + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 320 }) + try { + mount() + await openPicker() + const pop = screen.getByTestId('branch-switcher') + expect(pop.style.width).toBe('304px') + expect(Number.parseFloat(pop.style.left) + 304).toBeLessThanOrEqual(320 - 8) + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: original }) + } + }) + + it('reads the branch list only once opened', async () => { + mount() + expect(screen.getByTestId('branch-switcher-trigger')).toHaveTextContent('main') + expect(H.api.projectGitBranches).not.toHaveBeenCalled() + await openPicker() + expect(H.api.projectGitBranches).toHaveBeenCalledWith(PROJECT) + expect(screen.getByText('Local')).toBeInTheDocument() + // The count shows the section has rows even when they sit below the scroll edge. + expect(screen.getByTestId('branch-remote-heading')).toHaveTextContent('Remote (1)') + }) + + it('says on a remote row, outcome first, that a click switches and keeps edits', async () => { + mount() + await openPicker() + const opt = screen.getByText('review-fix').closest('[role="option"]')! + // The bare `origin/` prefix is dropped: the Remote heading already says where it is from. + expect(opt.querySelector('[data-testid="branch-row-name"]')).toHaveTextContent(/^review-fix$/) + const action = opt.querySelector('[data-testid="branch-remote-action"]') + expect(action).toHaveTextContent('Switch to review-fix. Downloads it here; your changes are kept.') + expect(opt).toHaveAttribute('title', "Switch to review-fix. Downloads it here; your changes are kept.\nYour changes are kept.") + // Local rows are a plain switch and carry no such line. + const local = screen.getByText('feature/login').closest('[role="option"]')! + expect(local.querySelector('[data-testid="branch-remote-action"]')).toBeNull() + }) + + it('gives an unswitchable remote row no action line', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, remote: [row('origin/feat+1', { switchable: false })] }) + mount() + await openPicker() + const opt = screen.getByText('feat+1').closest('[role="option"]')! + expect(opt.querySelector('[data-testid="branch-remote-action"]')).toBeNull() + }) + + it('keeps the remote prefix when two remotes carry the same branch', async () => { + H.api.projectGitBranches.mockResolvedValue({ + ...LIST, + remote: [row('origin/review-fix'), row('upstream/review-fix'), row('origin/solo')], + }) + mount() + await openPicker() + const names = screen.getAllByTestId('branch-row-name').map(n => n.textContent) + expect(names).toEqual(expect.arrayContaining(['origin/review-fix', 'upstream/review-fix', 'solo'])) + expect(names).not.toContain('review-fix') + }) + + it('switches to a local branch and refreshes every working-tree view', async () => { + const { invalidate } = mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feature/login' }) + const keys = invalidate.mock.calls.map(c => JSON.stringify((c[0] as { queryKey: unknown }).queryKey)) + for (const k of [['git-status', PROJECT], ['git-log', PROJECT], ['git-branches', PROJECT], ['project-tree', PROJECT], ['project-git']]) { + expect(keys).toContain(JSON.stringify(k)) + } + }) + + it('checks out a remote branch as a tracking branch with its short name', async () => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('review-fix')) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalled()) + expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'review-fix', track: 'origin/review-fix' }) + }) + + it('offers to create a typed name that does not exist, but only a deliberate key creates it', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + // The row says what the new branch starts from: the checked-out branch. + expect(screen.getByTestId('branch-create')).toHaveTextContent('New branch “feat/new-thing” from main') + // Highlighted (it is the only row), it names the key that creates it (jsdom is not a Mac). + expect(screen.getByTestId('branch-create-shortcut')).toHaveTextContent('Press Ctrl+Enter or click to create it') + // A plain Enter (or Tab) after a search must not turn a typo into a branch. + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Tab' }) + await new Promise(r => setTimeout(r, 0)) + expect(H.api.projectGitSwitch).not.toHaveBeenCalled() + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', ctrlKey: true }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) + }) + + it('creates on ⌘+Enter too', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', metaKey: true }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) + }) + + it('creates on a click of the create row', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.mouseDown(screen.getByTestId('branch-create')) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true })) + }) + + it('starts the highlight on a matching branch, not the create row', async () => { + mount() + await openPicker() + // "feat" matches feature/login and is also a valid new name. + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat' } }) + expect(screen.getByTestId('branch-create')).toBeInTheDocument() + const selected = screen.getAllByRole('option').find(el => el.getAttribute('aria-selected') === 'true') + expect(selected).not.toBe(screen.getByTestId('branch-create')) + }) + + it('shows the create key hint only while the create row is highlighted', async () => { + mount() + await openPicker() + // "feat" matches feature/login, so the highlight starts there. + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat' } }) + expect(screen.getByTestId('branch-create')).toHaveTextContent('New branch “feat” from main') + expect(screen.queryByTestId('branch-create-shortcut')).toBeNull() + fireEvent.mouseEnter(screen.getByTestId('branch-create')) + expect(screen.getByTestId('branch-create-shortcut')).toHaveTextContent('Press Ctrl+Enter or click to create it') + }) + + it('names the commit a new branch starts from when none is checked out', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, current: null, detached: true, head: 'abc1234' }) + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + expect(screen.getByTestId('branch-create')).toHaveTextContent('New branch “feat/new-thing” from abc1234') + }) + + it('does not offer creation for an invalid name', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'bad..name' } }) + expect(screen.queryByTestId('branch-create')).toBeNull() + expect(screen.getByText('Not a valid branch name. It can\'t contain “..” or end a part with “.” or “.lock”.')).toBeInTheDocument() + }) + + it('never switches to the current branch or a redacted one', async () => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('secret')) + fireEvent.mouseDown(screen.getAllByText('main').find(el => el.closest('[role="option"]'))!) + expect(H.api.projectGitSwitch).not.toHaveBeenCalled() + }) + + it('shows the localized reason when uncommitted changes block the switch and stays open', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + // Names the branch the switch targeted, so the reader can tell it failed. + expect(notice).toHaveTextContent("Couldn't switch to feature/login") + expect(notice).toHaveTextContent("Not switched: this branch would overwrite some of your uncommitted changes. Commit them, or ask the agent in this chat to commit or stash them, then switch again. (In a terminal: git commit or git stash.)") + expect(screen.getByTestId('branch-switcher')).toBeInTheDocument() + // The "never overwritten" line would contradict the refusal beside it. + expect(screen.queryByTestId('branch-switcher-safety')).toBeNull() + }) + + it('names the files that block a dirty-tree switch', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty', { files: ['README.md', 'src/app.py'], files_more: 2 })) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent('then switch again. (In a terminal: git commit or git stash.) Files in the way: README.md, src/app.py +2.') + }) + + it('leaves the dirty-tree refusal unchanged when git names no files', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty', { files: [] })) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).not.toHaveTextContent('Files in the way') + }) + + it('states the safety promise above the list, on the search field and on each row it applies to', async () => { + mount() + await openPicker() + const safety = screen.getByTestId('branch-switcher-safety') + expect(safety).toHaveTextContent("Your changes are safe: switching never overwrites files you've changed but not committed. If it would, nothing changes and you're told which files are in the way. After a switch, this folder shows that branch's version of its files.") + // The search field owns keyboard focus while arrows move the highlight, so it carries the description. + expect(screen.getByRole('combobox')).toHaveAttribute('aria-describedby', safety.id) + const row = screen.getByText('feature/login').closest('[role="option"]')! + expect(row).toHaveAttribute('title', "feature/login\nYour changes are kept.") + // The current branch is not a switch, so it does not repeat the promise. + const current = screen.getAllByTestId('branch-row-name').find(n => n.textContent === 'main')!.closest('[role="option"]')! + expect(current).toHaveAttribute('title', 'main') + }) + + it('brings the safety line back once the refusal is cleared by typing', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await screen.findByTestId('branch-switcher-error') + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'f' } }) + expect(screen.getByTestId('branch-switcher-safety')).toBeInTheDocument() + }) + + it('confirms a landed switch beside the trigger and announces it politely', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + mount() + const status = screen.getByTestId('branch-switcher-status') + expect(status).toHaveAttribute('role', 'status') + expect(status).toHaveTextContent('') + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + const note = await screen.findByTestId('branch-switcher-note') + expect(note).toHaveAttribute('data-note', 'switched') + expect(note).toHaveTextContent('Switched to feature/login') + expect(status).toHaveTextContent('Switched to feature/login') + // It offers the way back, so it stays long enough to reach it. + expect(screen.getByTestId('branch-switch-back')).toHaveTextContent('Switch back to main') + await vi.advanceTimersByTimeAsync(4100) + expect(screen.getByTestId('branch-switcher-note')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(6000) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + expect(status).toHaveTextContent('') + } finally { + vi.useRealTimers() + } + }) + + it('switches back to the previous branch from the confirmation in one click', async () => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const back = await screen.findByTestId('branch-switch-back') + expect(screen.getByTestId('branch-switcher-note')).not.toHaveClass('pointer-events-none') + H.api.projectGitSwitch.mockClear().mockResolvedValue({ ok: true, branch: 'main' }) + fireEvent.click(back) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'main' })) + await waitFor(() => expect(screen.getByTestId('branch-switcher-note')).toHaveTextContent('Switched to main')) + // The list still names main as checked out, so the way back is not offered again. + expect(screen.queryByTestId('branch-switch-back')).toBeNull() + }) + + it('drops the way back when the host moves to another repository', async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) + const tree = (project: string) => ( + + + + ) + const { rerender } = render(tree(PROJECT)) + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await screen.findByTestId('branch-switch-back') + rerender(tree(PROJECT + '-other')) + expect(screen.queryByTestId('branch-switch-back')).toBeNull() + }) + + it.each([1, 2])('never switches on a non-primary mouse button (%i)', async button => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login'), { button }) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.mouseDown(screen.getByTestId('branch-create'), { button }) + // Positive control: a primary press on the create row is the ONLY switch. + // Had a secondary press started one, it would have been the first call and + // its pending state would have blocked this press. + fireEvent.mouseDown(screen.getByTestId('branch-create'), { button: 0 }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalled()) + expect(H.api.projectGitSwitch).toHaveBeenCalledTimes(1) + expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feat/new-thing', create: true }) + }) + + it('opens the picker with the reason when switching back is refused', async () => { + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const back = await screen.findByTestId('branch-switch-back') + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + fireEvent.click(back) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't switch to main") + expect(screen.getByTestId('branch-switcher')).toBeInTheDocument() + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + }) + + it('offers no way back from a detached HEAD', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, current: null, detached: true, head: 'abc1234' }) + mount({ detached: true, branch: 'abc1234' }) + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await waitFor(() => expect(screen.getByTestId('branch-switcher-note')).toHaveTextContent('Switched to feature/login')) + expect(screen.queryByTestId('branch-switch-back')).toBeNull() + }) + + it('hides the way back once a response starts', async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) + const tree = (blocked: boolean) => ( + + + + ) + const { rerender } = render(tree(false)) + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await screen.findByTestId('branch-switch-back') + rerender(tree(true)) + expect(screen.queryByTestId('branch-switch-back')).toBeNull() + }) + + it('confirms nothing when the switch is refused', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + await screen.findByTestId('branch-switcher-error') + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + expect(screen.getByTestId('branch-switcher-status')).toHaveTextContent('') + }) + + it('says another running session blocked the switch when the server refuses it', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_session_busy')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't switch to feature/login") + expect(notice).toHaveTextContent('Not switched: an agent is still working in this repository, in this chat or another one. Wait for it to finish, or stop it in that chat (■ next to Send), then switch.') + }) + + it('names the local branch a remote row would create when the switch fails', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_dirty')) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('review-fix')) + expect(await screen.findByTestId('branch-switcher-error')).toHaveTextContent("Couldn't switch to review-fix") + }) + + it('names the branch a failed create was for', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_branch_exists')) + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter', ctrlKey: true }) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't create branch feat/new-thing") + expect(notice).toHaveTextContent('A branch with that name already exists.') + }) + + it('falls back to git’s own line for an uncoded failure', async () => { + H.api.projectGitSwitch.mockRejectedValue(apiError('git_switch_failed', { detail: 'fatal: something odd' })) + mount() + await openPicker() + fireEvent.mouseDown(screen.getByText('feature/login')) + const notice = await screen.findByTestId('branch-switcher-error') + expect(notice).toHaveTextContent("Couldn't switch to feature/login") + expect(notice).toHaveTextContent('fatal: something odd') + }) + + it('explains a filter-driver block and disables every row', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + // Leads with the outcome and its reason, then the next step. + expect(screen.getByTestId('branch-switcher-blocked')).toHaveTextContent( + "Switching and creating branches are off for this repository. It uses a checkout filter, a program Git runs on files whenever it switches branches, and this panel won't run it. Switch in a terminal instead.") + fireEvent.mouseDown(screen.getByText('feature/login')) + expect(H.api.projectGitSwitch).not.toHaveBeenCalled() + }) + + it('offers no create row while a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/new-thing' } }) + expect(screen.queryByTestId('branch-create')).toBeNull() + }) + + it('mutes every row while a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + fireEvent.mouseEnter(screen.getByText('feature/login').closest('[role="option"]')!) + for (const opt of screen.getAllByRole('option')) { + expect(opt).toHaveAttribute('aria-disabled', 'true') + expect(opt).toHaveAttribute('data-muted', 'true') + expect(opt.className).toContain('opacity-50') + // No pointer or keyboard highlight on a row that cannot be chosen. + expect(opt.className).not.toContain('bg-bg-hover') + } + }) + + it('keeps rows at full strength when switching is allowed', async () => { + mount() + await openPicker() + const row = screen.getByText('feature/login').closest('[role="option"]')! + fireEvent.mouseEnter(row) + expect(row).not.toHaveAttribute('data-muted') + expect(row.className).toContain('bg-bg-hover') + }) + + it('leads the line above the list with the safety guarantee, then says what a switch does', async () => { + mount() + await openPicker() + const hint = screen.getByTestId('branch-switcher-safety') + expect(hint.textContent).toMatch(/^Your changes are safe: switching never overwrites/) + // Sits above the list, so it is read before any row. + expect(hint.compareDocumentPosition(screen.getByRole('listbox')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('leaves the safety line out when a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + expect(screen.queryByTestId('branch-switcher-safety')).toBeNull() + }) + + it('says in words what the ahead/behind arrows count', async () => { + H.api.projectGitBranches.mockResolvedValue({ + ...LIST, + local: [row('main', { current: true }), row('feature/login', { ahead: 2, behind: 1 }), row('solo', { ahead: 1 })], + }) + mount() + await openPicker() + const [both, one] = screen.getAllByTestId('branch-ahead-behind') + expect(both).toHaveAttribute('title', '2 commits ahead of its remote branch, 1 commit behind its remote branch') + expect(one).toHaveAttribute('title', '1 commit ahead of its remote branch') + // The arrows are hidden from assistive tech; the words are read instead. + expect(both.querySelector('[aria-hidden="true"]')).toHaveTextContent('↑2 ↓1') + expect(both.querySelector('.sr-only')).toHaveTextContent('2 commits ahead of its remote branch, 1 commit behind its remote branch') + }) + + it('leaves out the Remote heading when no remote branch is listed', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, remote: [] }) + mount() + await openPicker() + expect(screen.getByText('Local')).toBeInTheDocument() + expect(screen.queryByTestId('branch-remote-heading')).toBeNull() + }) + + it('says once in the footer what the arrow pills count', async () => { + mount() + await openPicker() + expect(screen.getByTestId('branch-ahead-behind-legend')).toHaveTextContent('↑ ahead · ↓ behind its remote branch') + }) + + it('leaves the arrow legend out when no row has a pill', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, local: [row('main', { current: true }), row('feature/login')] }) + mount() + await openPicker() + expect(screen.queryByTestId('branch-ahead-behind-legend')).toBeNull() + }) + + it('disables the search field while a filter driver blocks switching', async () => { + H.api.projectGitBranches.mockResolvedValue({ ...LIST, switchBlocked: 'filter' }) + mount() + await openPicker() + await screen.findByTestId('branch-switcher-blocked') + expect(screen.getByRole('combobox')).toBeDisabled() + // ...and looks it: a disabled field drawn like an active one reads as broken. + expect(screen.getByRole('combobox').className).toMatch(/disabled:opacity-50/) + }) + + it('keeps the search field usable when switching is allowed', async () => { + mount() + await openPicker() + expect(screen.getByRole('combobox')).toBeEnabled() + }) + + it('fades the list edge while rows sit below it, and drops the fade at the end', async () => { + // jsdom lays nothing out, so give the list a scroll geometry by hand. + const geometry = { scrollHeight: 400, clientHeight: 200, scrollTop: 0 } + const spies = (['scrollHeight', 'clientHeight', 'scrollTop'] as const).map(prop => + vi.spyOn(HTMLElement.prototype, prop, 'get').mockImplementation(function (this: HTMLElement) { + return this.id === 'branch-switcher-list' ? geometry[prop] : 0 + })) + try { + mount() + await openPicker() + await waitFor(() => expect(screen.getByTestId('branch-switcher-more-below')).toBeInTheDocument()) + expect(screen.getByTestId('branch-switcher-more-below')).toHaveAttribute('aria-hidden', 'true') + geometry.scrollTop = 200 + fireEvent.scroll(document.getElementById('branch-switcher-list')!) + expect(screen.queryByTestId('branch-switcher-more-below')).toBeNull() + } finally { + spies.forEach(s => s.mockRestore()) + } + }) + + it('draws no fade when every row fits', async () => { + mount() + await openPicker() + expect(screen.queryByTestId('branch-switcher-more-below')).toBeNull() + }) + + it('closes on Escape', async () => { + mount() + await openPicker() + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + }) +}) + +describe('BranchSwitcher while switching is blocked', () => { + let originalClipboard: PropertyDescriptor | undefined + const stubClipboard = () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + return writeText + } + afterEach(() => { + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard) + else delete (navigator as { clipboard?: unknown }).clipboard + originalClipboard = undefined + }) + + it('copies the branch name instead of opening the picker', async () => { + const writeText = stubClipboard() + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toBeEnabled() + expect(trigger).toHaveAttribute('data-mode', 'copy') + expect(trigger).toHaveAccessibleName('Copy branch name main') + expect(trigger).toHaveAccessibleDescription('Copy branch name. To switch branch, first stop the response with ■ next to Send.') + // The reason is drawn visibly, not left to a delayed native tooltip. + expect(trigger).not.toHaveAttribute('title') + expect(trigger).not.toHaveAttribute('aria-haspopup') + fireEvent.click(trigger) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('main')) + await waitFor(() => expect(trigger).toHaveAccessibleName('Copied branch name main')) + // Touch has no hover, so the copy itself shows how to switch. + expect(screen.getByTestId('branch-switcher-note')).toHaveTextContent('Copied. To switch branch, first stop the response with ■ next to Send.') + expect(screen.queryByTestId('branch-switcher')).toBeNull() + expect(H.api.projectGitBranches).not.toHaveBeenCalled() + }) + + it('calls a detached HEAD a commit', async () => { + const writeText = stubClipboard() + mount({ switchBlocked: true, detached: true, branch: 'a1b2c3d' }) + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toHaveAccessibleName('Copy commit a1b2c3d') + expect(trigger).toHaveAccessibleDescription('Copy commit. To switch branch, first stop the response with ■ next to Send.') + fireEvent.click(trigger) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('a1b2c3d')) + }) + + it('shows the reason visibly on hover and on keyboard focus', () => { + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + fireEvent.mouseEnter(trigger) + expect(screen.getByTestId('branch-switcher-note')).toHaveTextContent('Copy branch name. To switch branch, first stop the response with ■ next to Send.') + expect(screen.getByTestId('branch-switcher-note')).toHaveAttribute('data-note', 'busy') + fireEvent.mouseLeave(trigger) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + fireEvent.focus(trigger) + expect(screen.getByTestId('branch-switcher-note')).toBeInTheDocument() + fireEvent.blur(trigger) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + }) + + it('shows no busy note while switching is allowed', () => { + mount() + fireEvent.mouseEnter(screen.getByTestId('branch-switcher-trigger')) + expect(screen.queryByTestId('branch-switcher-note')).toBeNull() + }) + + it('looks busy on the trigger itself while switching is blocked', () => { + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + // Visible without hovering: muted text and a copy cursor. + expect(trigger).toHaveClass('text-muted', 'cursor-copy') + expect(trigger).toHaveAttribute('data-busy', 'true') + // Static: it lasts the whole response, in peripheral vision. + expect(trigger.querySelector('.animate-spin')).toBeNull() + }) + + it('keeps the trigger at full strength while switching is allowed', () => { + mount() + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toHaveClass('text-text', 'cursor-pointer') + expect(trigger).not.toHaveClass('text-muted') + expect(trigger).not.toHaveAttribute('data-busy') + }) + + it('withholds the confirmation and shows an error notice when the copy fails', async () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') + const execCommand = vi.fn().mockReturnValue(false) + Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true }) + try { + mount({ switchBlocked: true }) + const trigger = screen.getByTestId('branch-switcher-trigger') + fireEvent.click(trigger) + await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy')) + expect(trigger).toHaveAccessibleName('Copy branch name main') + const notice = await screen.findByTestId('branch-switcher-copy-error') + expect(notice).toHaveAttribute('role', 'alert') + expect(notice).toHaveTextContent("Couldn't copy “main” to the clipboard.") + // No agent hand-off: it would move the chat away from the composer draft. + expect(notice.querySelector('a, button')).toBeNull() + expect(screen.getByTestId('branch-switcher-note')).toHaveAttribute('data-note', 'copy_failed') + // A next step: retrying the copy, which clears the notice once it lands. + writeText.mockResolvedValue(undefined) + fireEvent.click(screen.getByTestId('branch-switcher-copy-retry')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher-copy-error')).toBeNull()) + expect(writeText).toHaveBeenLastCalledWith('main') + } finally { + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand) + else delete (document as { execCommand?: unknown }).execCommand + } + }) + + it('clears the copy failure on its own', async () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, configurable: true }) + const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') + Object.defineProperty(document, 'execCommand', { value: vi.fn().mockReturnValue(false), configurable: true }) + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + mount({ switchBlocked: true }) + fireEvent.click(screen.getByTestId('branch-switcher-trigger')) + await screen.findByTestId('branch-switcher-copy-error') + await vi.advanceTimersByTimeAsync(8100) + expect(screen.queryByTestId('branch-switcher-copy-error')).toBeNull() + } finally { + vi.useRealTimers() + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand) + else delete (document as { execCommand?: unknown }).execCommand + } + }) + + it('says inside the picker when the footer copy is refused, and clears it once a copy lands', async () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') + const execCommand = vi.fn().mockReturnValue(false) + Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true }) + try { + mount() + await openPicker() + const footerCopy = screen.getByRole('button', { name: /Copy branch name/ }) + fireEvent.click(footerCopy) + const notice = await screen.findByTestId('branch-switcher-footer-copy-failed') + expect(notice).toHaveAttribute('role', 'alert') + expect(notice).toHaveTextContent("Couldn't copy “main” to the clipboard.") + expect(notice.querySelector('a, button')).toBeNull() + writeText.mockResolvedValue(undefined) + fireEvent.click(screen.getByTestId('branch-switcher-footer-copy-retry')) + await waitFor(() => expect(screen.queryByTestId('branch-switcher-footer-copy-failed')).toBeNull()) + expect(writeText).toHaveBeenLastCalledWith('main') + } finally { + if (originalExecCommand) Object.defineProperty(document, 'execCommand', originalExecCommand) + else delete (document as { execCommand?: unknown }).execCommand + } + }) + + it('opens the picker again once switching is allowed', async () => { + mount() + const trigger = screen.getByTestId('branch-switcher-trigger') + expect(trigger).toHaveAttribute('data-mode', 'switch') + expect(trigger).toHaveAccessibleName('Switch branch (current: main)') + await openPicker() + expect(screen.getByTestId('branch-switcher')).toBeInTheDocument() + }) +}) + +describe('isValidNewBranchName', () => { + it.each(['main', 'feat/x', 'release-1.2', 'a_b'])('accepts %s', name => { + expect(isValidNewBranchName(name)).toBe(true) + }) + it.each(['', '-x', 'a..b', 'x.lock', 'HEAD', 'feat/', 'a b', 'feat/CON', 'a~1'])('rejects %s', name => { + expect(isValidNewBranchName(name)).toBe(false) + }) +}) + +describe('newBranchNameProblem', () => { + it.each([ + ['a'.repeat(201), 'length'], + ['', 'chars'], + ['-x', 'chars'], + ['a b', 'chars'], + ['feat/', 'chars'], + ['a..b', 'dots'], + ['x.lock', 'dots'], + ['feat/x.', 'dots'], + ['HEAD', 'reserved'], + ['feat/CON', 'reserved'], + ['feat/x', null], + ] as const)('%s breaks %s', (name, problem) => { + expect(newBranchNameProblem(name)).toBe(problem) + }) + + it('names the broken rule in the picker', async () => { + mount() + await openPicker() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'a b' } }) + expect(screen.getByText('Not a valid branch name. Use only letters, numbers, “.”, “_”, “-” and “/”, and start each part with a letter or number.')).toBeInTheDocument() + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'feat/CON' } }) + expect(screen.getByText('Not a valid branch name. That name is reserved (HEAD, or a Windows device name such as CON).')).toBeInTheDocument() + }) +}) + +describe('BranchSwitcher keyboard start', () => { + it('highlights the first switchable row, not the checked-out branch', async () => { + mount() + await openPicker() + const selected = screen.getAllByRole('option').find(el => el.getAttribute('aria-selected') === 'true') + expect(selected).toHaveTextContent('feature/login') + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' }) + await waitFor(() => expect(H.api.projectGitSwitch).toHaveBeenCalledWith({ path: PROJECT, branch: 'feature/login' })) + }) +}) + +describe('notePlacement', () => { + const chip = { top: 700, bottom: 720, left: 100, right: 180 } + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }) + }) + + it('keeps a chip note on the chip row, clear of the composer above it', () => { + // Bottom-aligned with the chip and to its right, never above it. + expect(notePlacement('chip', chip, 280)).toEqual({ bottom: 80, left: 186 }) + }) + + it('moves a chip note to the left of the chip when the right side has no room', () => { + const right = { top: 700, bottom: 720, left: 900, right: 1000 } + expect(notePlacement('chip', right, 280)).toEqual({ bottom: 80, right: 306 }) + }) + + it('falls back above the chip only when neither side fits', () => { + Object.defineProperty(window, 'innerWidth', { value: 400, configurable: true }) + expect(notePlacement('chip', { top: 700, bottom: 720, left: 60, right: 340 }, 280)).toEqual({ bottom: 104, left: 60 }) + }) + + it('drops a header note below the trigger', () => { + expect(notePlacement('header', { top: 40, bottom: 60, left: 100, right: 180 }, 280)).toEqual({ top: 64, left: 100 }) + }) +}) + +describe('BranchSwitcher across a Crew switch', () => { + it('opens in the local overlay root and closes when the local Crew is hidden', async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }) + const tree = (hidden: boolean) => ( + + + + ) + const { rerender } = render(tree(false)) + await openPicker() + const picker = screen.getByTestId('branch-switcher') + expect(picker.closest('[data-local-overlay-root]')).not.toBeNull() + rerender(tree(true)) + await waitFor(() => expect(screen.queryByTestId('branch-switcher')).toBeNull()) + }) +}) diff --git a/website/src/test/ChatInput.projectBranch.test.tsx b/website/src/test/ChatInput.projectBranch.test.tsx index d85d54eba97..c643b1a3c4b 100644 --- a/website/src/test/ChatInput.projectBranch.test.tsx +++ b/website/src/test/ChatInput.projectBranch.test.tsx @@ -5,18 +5,39 @@ import userEvent from '@testing-library/user-event' import { renderWithProviders } from './helpers' import ChatInput from '../components/ChatInput' import { SlotProvider } from '../providers/SlotContext' +import { api } from '../api/client' +import type { GitBranchList } from '../api/client/files' -// Pins the project-chip branch contract: the active project button -// shows " · " so the session's git context is visible without -// opening a picker, and degrades to the folder name alone when there is no -// branch to show (not a repo, git unavailable, path gone). +// The chip loads its switcher behind import(); that boundary is pinned in +// LazyBranchSwitcher.test.tsx, so here the switcher renders synchronously. +vi.mock('../components/LazyBranchSwitcher', async () => ({ + default: (await import('../components/BranchSwitcher')).default, +})) + +// Pins the project-chip branch contract: the active project pill shows +// " · " so the session's git context is visible without opening +// a picker, degrades to the folder name alone when there is no branch to show +// (not a repo, git unavailable, path gone), and the branch segment opens the +// branch picker for the session's project folder. + +const PROJECT = '/home/u/work/KiroCrew' const defaultProps = { value: '', onChange: vi.fn(), onSend: vi.fn(), onProjectClick: vi.fn(), - project: '/home/u/work/KiroCrew', + project: PROJECT, +} + +const LIST: GitBranchList = { + repo: true, + current: 'feat/example', + local: [ + { name: 'feat/example', date: '2026-10-01T00:00:00Z', author: 'Ada', subject: 'wip', switchable: true, current: true }, + { name: 'main', date: '2026-09-30T00:00:00Z', author: 'Ada', subject: 'release', switchable: true }, + ], + remote: [], } beforeEach(() => { @@ -24,24 +45,40 @@ beforeEach(() => { localStorage.clear() }) +let originalClipboard: PropertyDescriptor | undefined + +const stubClipboard = () => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + return writeText +} + +afterEach(() => { + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard) + else delete (navigator as { clipboard?: unknown }).clipboard + originalClipboard = undefined +}) + const chip = () => screen.getByRole('button', { name: /Project: |Select project/ }) -const branchBtn = () => screen.getByRole('button', { name: /Cop(y|ied) (branch name|commit) / }) +const branchBtn = () => screen.getByTestId('branch-switcher-trigger') describe('ChatInput project chip branch label', () => { it('renders the branch beside the folder name', () => { renderWithProviders() expect(chip()).toHaveTextContent('KiroCrew') expect(branchBtn()).toHaveTextContent('feat/example') + expect(branchBtn()).toHaveAccessibleName('Switch branch (current: feat/example)') expect(chip().getAttribute('title')).toContain('Branch: feat/example') - expect(chip().getAttribute('title')).toContain('/home/u/work/KiroCrew') + expect(chip().getAttribute('title')).toContain(PROJECT) }) it('shows only the folder name when no branch is known', () => { renderWithProviders() const btn = chip() expect(btn).toHaveTextContent('KiroCrew') - expect(btn.getAttribute('title')).toBe('Project: /home/u/work/KiroCrew') - expect(screen.queryByRole('button', { name: /Copy branch name/ })).not.toBeInTheDocument() + expect(btn.getAttribute('title')).toBe(`Project: ${PROJECT}`) + expect(screen.queryByTestId('branch-switcher-trigger')).not.toBeInTheDocument() // No separator glyph without a branch to separate. expect(btn.textContent).not.toContain('·') }) @@ -51,8 +88,6 @@ describe('ChatInput project chip branch label', () => { expect(branchBtn()).toHaveTextContent('a1b2c3d') expect(chip().getAttribute('title')).toContain('Detached HEAD at a1b2c3d') expect(chip().getAttribute('title')).not.toContain('Branch:') - // The copy affordance calls it a commit, not a branch. - expect(screen.getByRole('button', { name: 'Copy commit a1b2c3d' })).toBeInTheDocument() }) it('names the same project and branch while a response is running, plus when a pick lands', () => { @@ -63,10 +98,42 @@ describe('ChatInput project chip branch label', () => { expect(btn).toHaveAccessibleName(/This reply finishes in KiroCrew\./) }) - it('leaves the branch copyable while a response is running', () => { + it('leaves the branch copyable while a response is running', async () => { + const writeText = stubClipboard() + const list = vi.spyOn(api, 'projectGitBranches').mockResolvedValue(LIST) renderWithProviders() - // Switching project mid-run is unsafe; reading the branch name is not. + // Switching mid-run is unsafe, since a checkout would change files under the + // agent; reading the branch name is not. The segment stays a live control + // that copies, and its tooltip says how to switch. expect(branchBtn()).not.toBeDisabled() + expect(branchBtn()).toHaveAccessibleName('Copy branch name main') + expect(branchBtn()).toHaveAccessibleDescription('Copy branch name. To switch branch, first stop the response with ■ next to Send.') + fireEvent.click(branchBtn()) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('main')) + await waitFor(() => expect(branchBtn()).toHaveAccessibleName('Copied branch name main')) + expect(screen.queryByTestId('branch-switcher')).not.toBeInTheDocument() + expect(list).not.toHaveBeenCalled() + }) + + it('calls a detached HEAD a commit when copying it mid-run', () => { + renderWithProviders() + expect(screen.getByRole('button', { name: 'Copy commit a1b2c3d' })).toBeInTheDocument() + }) + + it('keeps the message input focused while copying the branch mid-run', async () => { + // A real pointer click moves focus to the pressed button before `click` + // fires. The trigger cancels that transfer on mousedown, so a user who is + // typing a steer can copy the branch and keep typing. `userEvent` replays + // the full mousedown -> focus -> mouseup -> click sequence; `fireEvent.click` + // alone would never move focus and could not fail here. + const user = userEvent.setup() + const writeText = stubClipboard() + renderWithProviders() + const input = screen.getByRole('textbox', { name: 'Message input' }) + input.focus() + await user.click(branchBtn()) + expect(input).toHaveFocus() + await waitFor(() => expect(writeText).toHaveBeenCalledWith('feat/example')) }) it('falls back to the full path when the project has no basename', () => { @@ -74,7 +141,7 @@ describe('ChatInput project chip branch label', () => { expect(branchBtn()).toHaveTextContent('main') }) - it('does not nest the copy button inside the picker button', () => { + it('does not nest the branch button inside the project button', () => { // A