diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bb171df..24ebd760 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,8 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ - build-essential + build-essential \ + git-lfs - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.lock b/Cargo.lock index 4ea18990..430b11f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5924,9 +5924,11 @@ version = "1.5.1" dependencies = [ "git2", "gix", + "libc", "notify", "serde", "serde_json", + "tempfile", "thiserror 1.0.69", "tracing", ] @@ -5944,6 +5946,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1_smol", "sha2", "sqlx", "strand-azdo-protocol", diff --git a/PRD.md b/PRD.md index 91716aa7..764ac5f2 100644 --- a/PRD.md +++ b/PRD.md @@ -58,7 +58,7 @@ It will be built as a **Tauri 2** application with a **Rust** git backend and a | App shell | **Tauri 2** | True cross-platform, small bundles (~10MB vs Electron's ~100MB+), native webview per OS, signed installers built-in. | | Backend language | **Rust** | Speed, safety, and a great Git ecosystem (`gix`, `git2`). Pairs natively with Tauri. | | Git engine (read) | **`gix` (gitoxide)** | Pure-Rust, modern, dramatically faster than libgit2 for log/diff/status on large repos. | -| Git engine (write) | **`git2` (libgit2)** + shell-out to `git` | Use `git2` for commit/branch/merge/rebase where stable. Shell out to the user's `git` binary for ops that need it: interactive rebase, GPG signing, Git LFS, Git-flow, hooks. This is what Sublime Merge and Tower do — it's the right call. | +| Git engine (write) | **`git2` (libgit2)** + shell-out to `git` | Use `git2` for index/branch writes where stable. Commit/amend always use system Git for hook parity. Shell out to the user's `git` binary for ops that need it: interactive rebase, GPG signing, Git LFS, Git-flow, hooks. This is what Sublime Merge and Tower do — it's the right call. | | Frontend | **React + TypeScript** | Required by `@pierre/diffs` and `@pierre/trees`. | | Diff & code rendering | **`@pierre/diffs`** | Split/stacked diffs, merge conflict UI, line selection, annotations, Shiki themes. Covers most of §6.3. | | File tree | **`@pierre/trees`** | Virtualized (handles 100k+ files), Git status badges built in, drag-and-drop, search, keyboard nav, accessible. Covers §6.5. | diff --git a/README.md b/README.md index 40ac8df7..87fd2856 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,36 @@ the resolved app appearance automatically. ## Features +- **More hosting providers** — GitLab merge requests and Bitbucket Cloud pull + requests use the review workspace, with paged lists, comments, inline threads + and supported review decisions. GitHub Enterprise/custom hosts use their own + CLI authentication scope; select custom adapters in Settings → Hosting. + GitLab merges guard the reviewed head and follow project settings. Bitbucket + merge, GitLab request-changes and Bitbucket draft transitions remain + provider-site actions. Bitbucket Server is not supported. +- **Publish repository** — create an empty GitHub/Enterprise, GitLab or Bitbucket + Cloud repository after reviewing its account, destination and visibility. + Add the remote, then explicitly choose whether to push the reviewed commit. + Interrupted creation and remote setup can be resumed from the same dialog. + +- **Personal user actions** — define repository, ref, and working-tree file + commands in Settings → Integrations. Menu and palette entries open an exact + executable/argument/working-directory preview, with bounded output and cancellation. + +- **Repository size controls** — clone a chosen branch with optional depth, + single-branch fetching, on-demand file contents (`blob:none`), and recursive + submodules. Inspect clone scope and download more or full history from the + network menu or palette. Sparse checkout selects cone directories, distinguishes + excluded files from deletions, and preserves external sparse indexes on reads. + Selection changes refuse dirty trees and ignored-file removal. + +- **Git LFS** — repository setup, tracking patterns, object/transfer status, + downloads/uploads and server locks from the sidebar and command palette. + Whole-file staging, checkout, discard and hard reset honor LFS filters; + history is never migrated. +- **Submodule lifecycle** — add, remove, deinitialize, synchronize URLs, and + inspect nested modules in pages. Network work is cancellable; dirty module + contents, ignored files and unrecorded commits block removal/deinitialization. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with @@ -135,7 +165,7 @@ the resolved app appearance automatically. Codex or Claude Code subscription can inspect that exact review set for possible defects. Findings stay pending until you explicitly add selected ones as severity-labelled notes; AI review never edits repository files. -- **Hosted pull requests** — browse the latest 100 GitHub or Azure DevOps PRs +- **Hosted pull requests** — browse GitHub PRs with cursor pages or the latest 100 Azure DevOps PRs for the active repository, with the active PR for your checked-out branch opening and being followed automatically even before the PR view is opened. Create a PR or draft for the checked-out branch from the toolbar or command @@ -169,6 +199,12 @@ the resolved app appearance automatically. drafts expose **Ready for review** in place of Merge; active PRs merge with merge-commit, squash, or rebase through a GitHub-style split control and can update their source from the target on GitHub with an expected-head guard. + Explicit GitHub merge queue/auto-merge and Azure auto-complete controls show + provider capability, enable/cancel, policy state and queue position separately + from a completed merge. + **Review tools** saves a reviewed head, compares exact trees across pushes or + rebases, exports all unresolved feedback with replies, and previews validated + standard suggestions before applying them to a clean local file. Every GitHub or Azure PR can open its exact provider head in a new worktree without changing local refs or `FETCH_HEAD`. Active PRs can be closed from a confirmed overflow action. Closed PRs can be reopened; @@ -210,7 +246,11 @@ the resolved app appearance automatically. (fetch-first for remote bases) and copies gitignored setup files listed in `.worktreeinclude` (`.env`, local settings) so agents can run out of the box. Stale entries whose directories are already gone prune immediately. -- **Everyday Git** — stage, unstage, or recoverably discard whole change +- **Everyday Git** — hook-aware signed/unsigned commit and amend with checkout + session drafts and bounded output; effective repository author/committer identity + with local overrides in Settings → Git; repository/worktree signing settings, + per-operation signing choices, and signed-tag creation/verification; stage, + unstage, or recoverably discard whole change blocks or individually selected lines inline in the diff; bulk tree actions include every selected file and every changed file beneath selected folders; initialize a repository with an initial branch, optional @@ -231,6 +271,19 @@ the resolved app appearance automatically. merge, and a fully keyboard-operable interactive rebase (reorder, reword, edit/pause-to-amend, squash, fixup, drop, and merge preservation) with pause/conflict Continue / Abort. +- **Patch interchange** — preview affected paths and validate patch imports + into the working tree, index, or both; import mailboxes with original authors + and Continue / Skip / Abort recovery; verify bundle refs and prerequisites, + import into a new branch, and export full or incremental bundles. +- **Git-flow workflows** — opt-in Git-flow AVH detection and repository + configuration, reviewed feature/release/hotfix start and finish, streamed + output, and merge recovery that retains already completed stages. +- **Advanced Git refs** — inspect and edit Git notes and replacement refs; + retarget or re-annotate existing unsigned tags with old/new targets and + optional remote publication checks. External edits require a fresh review. +- **Guided bisect** — start from known good/bad revisions, test and mark + good/bad/skip, inspect remaining candidates and the culprit, resume external + sessions, and reset to the original checkout while protecting test edits. - **Commit graph** — SVG lanes with branch/tag chips, revealable inline stash nodes with non-mutating diff inspection, a resizable commit detail panel with lazy GPG/SSH/X.509 verification, diff --git a/ROADMAP.md b/ROADMAP.md index f048b0bf..1876bd02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2154,18 +2154,30 @@ and Store certification remain external gates. never touches credentials). Designed 2026-06-12: `docs/remote-ssh.md` + task breakdown in TASKS.md. Pre-1.0 guardrails (opaque repo paths, everything through the `commands.rs` seam) are active now. -- Git-flow (start/finish feature/release/hotfix; shells out to `git-flow`) -- Git LFS (status badges + progress) -- GPG / SSH commit signing UI +- ☑ Git-flow (F19: opt-in Git-flow AVH detection/configuration, reviewed + feature/release/hotfix start/finish and recovery from partial completion) +- ☑ Git LFS — filter-correct staging/checkout/discard/hard reset and explicit + setup, patterns, object/transfer status and server locks (`LfsDialog`, + `Repo::lfs_action`); real Git fixtures and native dialog verification pass. +- ☑ GPG / SSH commit signing UI (repository/worktree settings and per-operation + choices; signed-tag creation and verification, 2026-09-06). - Selectable beta updater channel (1.0 remains pinned to the signed stable GitHub Releases channel) - Opt-in product telemetry only if a concrete post-1.0 decision, disclosure, retention policy, and backend justify adding it -- Guided Git bisect +- ☑ Guided Git bisect (F10: manual good/bad/skip, progress/culprit, + external-session resume, clean-tree reset and reviewed original target) +- ☑ Advanced refs (F18: Git notes, replace refs, explicit tag retarget/re-annotation + with stale-write guards and remote publication checks; native desktop verified) - Sparse checkout (cone mode first) -- Patch import/mailbox and Git bundle workflows -- Expanded submodule lifecycle (add/remove/deinit/sync/URL/nested status) -- Repository/ref/file custom actions with safe argv templates +- ☑ Patch import/mailbox and Git bundle workflows (F07: `InterchangeDialog`, + validation, author-preserving mailbox recovery and new-branch bundle import; + native desktop flows verified) +- ☑ Expanded submodule lifecycle — guarded add/remove/deinit/sync/URL, paged + nested inspection and cancellable updates (`SubmoduleDialog`); real Git + preservation fixtures and native lifecycle/keyboard checks pass. +- ☑ Repository/ref/file custom actions with safe argv templates + (`UserActionsEditor`, context menus / Quick Launch, `UserActionDialog`) - **CLI companion binary (`strand`)** — `strand ` opens the repo in the app; `strand diff/log/status/review --json` gives AI agents typed, full-context data the `git` porcelain can't (same serde types @@ -2188,6 +2200,8 @@ and Store certification remain external gates. - ◐ Hosted-review expansion — GitLab/Bitbucket adapters, deeper pagination, merge queue/auto-complete, and review-evolution comparisons build on the completed GitHub/Azure 1.0 workspace. + GitLab, Bitbucket Cloud and custom GitHub host adapters are complete; + the remaining expansion items stay open. **AI commit messages (2026-07-01):** Subscription-first suggestions prefer the staged diff, and fall back to all unstaged changes when no staged diff exists — @@ -2808,8 +2822,135 @@ implementation rows while the July audit is labeled historical. This is a planning update, not a claim that these features shipped; existing local Git, GitHub/Azure review, Workbench and performance work retain their own status. +**Hosted GitHub pagination shipped (2026-09-06, F06):** Inbox and activated +review data now use cursor pages, including independent thread replies and +checks. Partial counts, retry/cancel controls and palette actions preserve +loaded content and drafts. Page appends deduplicate provider IDs and reject +head changes; inbox navigation remains shallow and hosted patches remain lazy. + +**Deferred hosted completion shipped (2026-09-06, F12):** GitHub queue and +auto-merge plus Azure auto-complete now expose capability-gated enable/cancel, +queue versus policy-wait states, and refresh after head changes. Immediate +GitHub merge uses the guarded REST endpoint. Azure Server adds protocol 7 +`SetAutoComplete`; its signed helper channel must ship with this app change. + +**Hosted review evolution shipped (2026-09-06, F13):** Review tools preserves a +locally marked reviewed head, compares exact trees across rebases/force pushes, +loads GitHub review commits or Azure iterations, exports complete unresolved +feedback, and previews standard suggestions before guarded local application. +Full discussion reads are explicit and cancellable; stale heads, bodies, +coordinates and dirty files cannot reuse a preview. WebView2 and live GitHub +read IPC checks pass; live Azure iteration/suggestion validation remains tracked. + +**Advanced refs and Git-flow shipped (2026-09-06):** Added lazy Git notes and +replacement-ref management, explicit unsigned tag retarget/re-annotation with +current/new target review and remote publication checks, and opt-in Git-flow +AVH configuration plus feature/release/hotfix start/finish. Stale external refs, +checkout/config changes and unresolved conflicts block reviewed writes. +Git-flow streams bounded output and preserves completed merges/tags when a +later merge is aborted. Native WebView2 exercised notes, replacements, tag +edits, all three workflow types, external-session recovery and keyboard focus. +The core suite passed (174 normal tests plus three explicit AVH integrations), +as did the frontend typecheck and five focused frontend tests. + --- +**Hosted adapters shipped (2026-09-06):** GitLab merge requests and Bitbucket +Cloud pull requests share the existing review workspace, with paged collections, +provider-specific inline coordinates, permission-gated controls and explicit +fallbacks for unsupported writes. Custom GitHub hosts use host-scoped CLI +authentication and per-remote adapter selection. GitHub.com and Azure routing +remain intact. Bitbucket merge stays on the provider because its API cannot +atomically guard the reviewed head. + +**Hosted repository publication shipped (2026-09-06):** Publish repository +reviews an authenticated GitHub/Enterprise, GitLab or Bitbucket Cloud destination, +creates an empty repository, adds its remote, then separately offers an explicit +push of the reviewed commit. A local recovery record survives partial failures; +uncertain creation checks the destination without repeating the create request. + +**Patch interchange shipped (2026-09-06):** Added affected-path previews, +explicit index/worktree targets, validated import, original-author mailboxes +with Continue/Skip/Abort, and bundle verification/import/export with +prerequisite/ref summaries. Native fixtures cover stale previews, all patch +targets, mailbox authors/recovery and missing bundle prerequisites. Rust +checks, five fixtures and three frontend IPC tests pass. Native WebView2 +verified palette/focus, worktree/index targets, mailbox authors and conflict +continuation, and bundle verification/import/incremental export. + +**Guided bisect shipped (2026-09-06):** The Repository menu, palette and +operation banner open a manual bisect dialog backed by worktree-local Git +state. It shows remaining candidates, final/ambiguous outcomes, custom +external terms and no-checkout sessions. Stale ratings and dirty checkout/ +reset transitions are refused. Three native fixtures, two frontend tests, +Rust checks/typecheck and native WebView2 rating/resume/reset/keyboard flows +pass. Automated test-command execution remains a separate later slice. + +**Personal user actions shipped (2026-09-06, F15):** Settings → Integrations +now edits explicit executable/argv definitions for repositories, qualified refs, +and working-tree files. Context menus and Quick Launch capture the target and +require a resolved executable/arguments/cwd preview. Native execution revalidates +paths and ref IDs, preserves argv boundaries, bounds both output streams, and +cancels the process tree. Definitions stay in personal settings, separate from +Workbench and plugins. Automated tests and an isolated Windows WebView2 pass +covered literal spaces/metacharacters, stale selections, exact menu targets, +nonzero exits, output limits, keyboard operation, and cancellation. + +**Sparse checkout and clone controls shipped (2026-09-06, F08/F09):** Clone now +offers branch, independent depth/single-branch choices, blob filtering and +recursive submodules. Repository history controls inspect external clones and +deepen/unshallow with progress and cancellation. Cone sparse selections can be +inspected, changed and disabled, with dirty/ignored-file guards and compatible +reads and mutations for external sparse indexes. Excluded files no longer look +deleted. Windows native UI verification and 13 integration fixtures cover +these workflows; normal repositories retain their existing in-process paths. +See `docs/sparse-clone-verification.md` for validation and supported boundaries. + +**LFS implementation shipped (2026-09-06):** Real fixtures exposed raw asset +blobs from git2 staging. LFS paths now stage in one literal NUL-delimited Git +batch; checkout, discard and hard reset honor required filters, and partial +pointer patches are rejected. The lazy sidebar/palette dialog exposes setup, +patterns, objects, transfers and server locks with bounded output and process +tree cancellation. Exact pointer/commit/network/lock fixtures and native +setup, staging, keyboard, cancellation and recovery checks pass. + +**Submodule lifecycle shipped (2026-09-06):** Added explicit add/remove/deinit, +URL changes/sync, lazy paged nested inspection, repository opening and +cancellable updates through the sidebar and palette. Removal and deinit retain +Git history and refuse dirty, unrecorded or ignored local data, including nested +modules. Real Git and native UI checks cover registration/index preservation, +network cancellation, URL edits, nesting, confirmations and reinitialization. + +--- + +**Commit hook parity shipped (2026-09-06, F01):** Signed and unsigned commit/amend +now run system Git’s applicable hooks and honor custom hooksPath, rejection +and rewritten messages. Checkout session drafts survive rejection/navigation; +expandable output retains bounded diagnostics. Native Ctrl+Enter flows and +core/store regressions passed; the loaded-host no-hook cost is recorded in +`docs/hooks-identity-signing-validation-2026-09-06.md`. Index/status paths are +unchanged; the commit-policy exception is explicit in PRD and learnings. + + +**Repository identity shipped (2026-09-06, F02):** Settings → Git and its palette +entry show effective author/committer identity with per-field source/scope. +Individual local name/email overrides can be saved or removed without editing +global or included files. Linked worktrees share local values; existing +worktree overrides remain effective. Conditional, two-repository and linked +worktree fixtures passed; native settings save/remove and repository switching +were exercised with isolated fixtures. + + +**Signing controls and signed tags shipped (2026-09-06, F03):** Repository and +enabled worktree settings show effective signing defaults, format, key and SSH +allowed-signers sources. Commit/amend and tag forms offer inherited, signed or +unsigned operations without changing defaults. Signed tags require an +annotation; lazy verification displays the immutable object and Git trust +diagnostics. Real GPG/SSH fixtures cover hooks, amend, linked worktrees, +unsigned overrides, tampering and failed signers. Native Windows settings, +commit/amend and palette tag flows passed; validation and platform limits are +recorded in `docs/hooks-identity-signing-validation-2026-09-06.md`. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 4c7910bf..4759a026 100644 --- a/TASKS.md +++ b/TASKS.md @@ -89,45 +89,69 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- (`docs/git-client-feature-audit-2026-09-06.md`: 19 missing/partial feature families, code evidence, priorities, fallbacks, and acceptance criteria). Priorities below are current recommendations, not historical PRD release gates. -- ☐ **F01 / P1 — Hook parity for unsigned commit/amend.** Resolve the recorded +- ☑ **F01 / P1 — Hook parity for unsigned commit/amend.** Resolve the recorded git2 commit-policy versus Git-hook contract tension; honor `core.hooksPath`, rejection and message rewriting, preserve drafts and bounded diagnostics, - and measure the no-hook path (`commit.rs`; signed commits already use Git). -- ☐ **F02 / P1 — Effective repository identity and scoped overrides.** Show + and measure the no-hook path (`Repo::commit`, bounded `git_output`, checkout + `commitDrafts`; evidence in `docs/hooks-identity-signing-validation-2026-09-06.md`). +- ☑ **F02 / P1 — Effective repository identity and scoped overrides.** Show the current author/committer identity, set/remove repo-local name/email - without changing global/conditional config, and verify linked worktrees. -- ☐ **F03 / P1 — Signing controls and signed tags.** Keep configured commit + without changing global/conditional config, and verify linked worktrees + (`repository_identity` / `repo_set_identity`, Settings → Git source display). +- ☑ **F03 / P1 — Signing controls and signed tags.** Keep configured commit signing/verification; add scoped format/key controls and signed-tag creation - with agent delegation and visible signing failures. -- ☐ **F04 / P1 — LFS compatibility and management.** First prove pointer/filter - correctness across single/bulk staging, checkout, commit and network flows; - then add setup/tracking/status/locks/progress. System-Git networking alone - does not establish end-to-end LFS support. -- ☐ **F05 / P1 — Submodule lifecycle.** Extend existing open/status/init/update - with add/remove/deinit/sync/URL/nested inspection; verify dirty-state handling, - `.gitmodules` and index changes, plus cancellable network operations. -- ☐ **F07 / P2 — Patch/mailbox/bundle import and interchange.** Build on exact + with agent delegation and visible signing failures (`signing_settings` / + `set_signing_config`, commit/tag `SigningChoice`, `TagVerificationDialog`; + real GPG/SSH and native Windows evidence in the F01–F03 validation note). +- ☑ **F04 / P1 — LFS compatibility and management.** Filter-aware single/bulk + staging, discard, checkout and hard reset; exact pointer/commit/push/pull and + missing-filter fixtures pass (`lfs.rs`). Local setup, patterns, object/transfer + status, bounded locks and cancellable transfers are exposed in `LfsDialog`. + Real lock-API fixtures and native setup/staging/palette/cancellation/recovery + checks pass; no eager LFS network or status subprocesses. +- ☑ **F05 / P1 — Submodule lifecycle.** Add/remove/deinit/sync/URL changes, + paged nested inspection and cancellable updates (`SubmoduleDialog`, + `Repo::{submodule_action,submodule_children}`). Real Git transport, dirty, + ignored and nested files, unrecorded commits and `.gitmodules`/index + preservation fixtures pass. Native lifecycle, keyboard/palette, module + opening, destructive guards and cancellation checks pass. +- ☑ **F07 / P2 — Patch/mailbox/bundle import and interchange.** Build on exact patch export and hunk apply with preview/validation, explicit targets, mailbox continue/skip/abort and bundle prerequisites/ref summaries. -- ☐ **F08 / P2 — Sparse checkout.** Cone-directory inspect/change/disable and - compatibility fixtures for excluded paths, dirty trees and sparse indexes. -- ☐ **F09 / P2 — Advanced clone options.** Branch, depth/single-branch, + Implemented (`interchange.rs`, `InterchangeDialog`, Repository menu/palette); + five native fixtures and three IPC tests pass. Native WebView2 verified patch + targets, authored mailbox/conflict continuation and bundle import/export. +- ☑ **F08 / P2 — Sparse checkout.** Cone-directory inspect/change/disable and + compatibility fixtures for excluded paths, dirty trees and sparse indexes + (`Repo::set_sparse_checkout`, `SparseCheckoutDialog`, `sparse_checkout.rs` fixtures). +- ☑ **F09 / P2 — Advanced clone options.** Branch, depth/single-branch, partial-clone filter and recursive-submodule options; deepen/unshallow, - progress/cancellation, and safe argument construction. -- ☐ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external + progress/cancellation, and safe argument construction (`clone_with_options`, + `repo_expand_history`, `CloneScopeDialog`; `docs/sparse-clone-verification.md`). + Follow-up: verify real LFS clone checkout on Git 2.45.1 / LFS 3.5.1; + see `docs/git-assets-validation-2026-09-06.md` for the hook rejection. +- ☑ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external session resume and safe reset to the original checkout; defer test-command - execution until the manual workflow is complete. -- ☐ **F14 / P2 — Publish a new hosted repository.** Provider/account/visibility + execution (`bisect.rs`, `BisectDialog`, Repository menu/palette and banner; + three native fixtures, two frontend tests and native WebView2 keyboard, + external-resume, dirty-reset and original-target checks). +- ☑ **F14 / P2 — Publish a new hosted repository.** Provider/account/visibility selection, concrete destination review, remote configuration and explicit - initial push, with recovery from partial failure. -- ☐ **F15 / P2 — User-defined repository/ref/file actions.** Safe executable/ + initial push, with recovery from partial failure (GitHub/Enterprise, GitLab + and Bitbucket Cloud; `PublishRepoDialog`, `hosted_publish_*`, persisted + recovery stages and exact-reviewed-commit push). +- ☑ **F15 / P2 — User-defined repository/ref/file actions.** Safe executable/ argv templates, exact context, palette/menu discovery, preview, bounded output - and cancellation; editor/terminal templates and internal registries already exist. -- ☐ **F18 / P3 — Advanced refs.** Git notes/replace-ref management and explicit + and cancellation. (`UserActionsEditor`, `UserActionDialog`, + `repo_user_action_preview` / `repo_user_action_run`; personally persisted settings.) +- ☑ **F18 / P3 — Advanced refs.** Git notes/replace-ref management and explicit tag retarget/re-annotation with current/new target review. Signed tags are F03; - existing local Review notes are separate from Git notes. -- ☐ **F19 / P3 — Git-flow orchestration.** Opt-in tool/config detection and + existing local Review notes are separate from Git notes. (`AdvancedRefsDialog`, + locked notes updates, replace refs and compare-and-swap tag edits; native verified.) +- ☑ **F19 / P3 — Git-flow orchestration.** Opt-in tool/config detection and inspectable start/finish feature/release/hotfix flows with conflict recovery. + (`GitflowDialog`, AVH command review/streaming, locked opt-in configuration + and merge-only recovery; all three flows and stale state verified natively.) Hosted-review F06/F11–F13 stay in the Pull requests backlog below. CLI/deep-link F16 and remote-SSH F17 keep their existing Platform / CLI companion / Remote @@ -1473,6 +1497,11 @@ community plugins, performance and platform certification from Git feature gaps. and focus-restore to the opener on close (captured pre-`autoFocus`). ### Cross-cutting +- ☐ Investigate Windows watcher burst timing: unchanged + `watch::tests::debounce_collapses_a_burst_into_one_callback` observed two + callbacks instead of one in the 2026-09-06 F01–F03 final full run and isolated + retry, after earlier full-suite passes. Reproduce and distinguish OS event + delivery from debounce/test timing before changing production behavior. - ☑ Resizable panes everywhere (`react-resizable-panels`); sizes persisted per-region via `autoSaveId` (`strand:body`, `strand:lc-main`, `strand:lc-files`) @@ -1867,16 +1896,18 @@ tree: watch the agent work, review fast, accept or reject safely. batched submission use GitHub's atomic review payload or Azure's bounded latest-iteration/change-tracking resolver (`azure_review_coordinates`, `azure_server_review_coordinates`). - - ☐ **F06 / P1 — Complete large-PR pagination.** Paginate GitHub inbox, + - ☑ **F06 / P1 — Complete large-PR pagination.** Paginate GitHub inbox, reviews, threads/replies and check contexts beyond the current bounded queries; expose partial/error states, deduplicate pages and test 101+ - entries while keeping initial queries shallow. + entries while keeping initial queries shallow (`pull_requests::pages`, + `PullRequestDataLoader`, `PullRequestInboxLoader`; 34 Rust / 38 frontend + tests and WebView2 101-row/review, failure, cancellation and stale-head pass). - ☑ Batched review submission: pending comments plus Comment / Approve / Request changes, summary preview, exact-head stale guard, and draft preservation when a provider write fails (`pullRequestReview` drafts, `PullRequestChanges` review composer, `repo_pull_request_submit_review`). - ☑ Searchable repository PR inbox (`filterPullRequests`, `.pr-inbox-*`): - All, Authored, and Completed filter the shallow latest-100 list locally; + All, Authored, and Completed filter the loaded shallow inbox pages locally; search covers number/title/author/source/target branches; provider-account identity drives Authored without hiding All when identity lookup fails; selection, j/k/arrows/Home/End/Enter, focus restoration, and palette search @@ -1892,20 +1923,32 @@ tree: watch the agent work, review fast, accept or reject safely. worktree…** for GitHub and Azure plus expected-head GitHub **Update branch from target** are shipped (`repo_pull_request_prepare_checkout`, `repo_pull_request_update_branch`, `PullRequestDetails.openBranchInWorktree`). - Reliable “since my last review” compare where the provider exposes a - boundary, suggestions, and unresolved-feedback export for external agents - remain 1.1 work. - - ☐ **F13 / P2 — Hosted review evolution.** Explicit reviewed-head/iteration + Reviewed-head comparisons, suggestions and unresolved-feedback export + ship in F13 below. + - ☑ **F13 / P2 — Hosted review evolution.** Explicit reviewed-head/iteration comparison, validated suggestion application and unresolved-feedback export; - handle rebases and force pushes without reusing stale coordinates. + handle rebases and force pushes without reusing stale coordinates + (`evolution::{boundaries,compare,feedback,preview,apply}`, `HostedReviewTools`, + 41 hosted Rust tests / 43 frontend tests and WebView2 comparison/export/ + suggestion/failure/cancellation/head-refresh pass; live GitHub read IPC). + - ☐ **F13 provider validation.** Exercise Azure Services/Server iteration + comparison and current-iteration suggestions with a live authenticated + fixture. Local coordinate/payload tests pass; hosted writes were not used. - ☑ 1.0 checks render provider states as green success, yellow running, red failure, or neutral. Azure PR policy evaluations now join readiness and background activity when their query succeeds; incomplete policy calls - remain neutral. Merge queue/auto-complete and richer required-review detail - remain 1.1 work. - - ☐ **F12 / P2 — Merge queue / auto-complete controls.** Provider capability, + remain neutral. Merge queue/auto-complete controls ship in F12 below; richer + required-review detail remains later work. + - ☑ **F12 / P2 — Merge queue / auto-complete controls.** Provider capability, enable/cancel, queued versus merged state, policy blockers and head refresh; - preserve GitHub queue versus Azure auto-complete semantics. + preserve GitHub queue versus Azure auto-complete semantics + (`completion::set`, `PullRequestCompletionControl`, helper protocol 7 + `SetAutoComplete`; 36 hosted Rust tests, 12 helper/protocol tests, WebView2 + enable/cancel/position/policy/head-refresh pass). + - ☐ **F12 release validation.** Publish the signed protocol-7 helper channel + with the desktop build and exercise queue/auto-complete against a live + policy-controlled GitHub and Azure Services/Server PR. Local tests use + provider payloads and WebView2 fixtures; no live hosted writes were made. - ☑ Hosted PR lifecycle actions. - ☑ Mark permission-backed drafts ready for review (`PullRequest.can_mark_ready`, `repo_pull_request_ready`, GitHub viewer @@ -1918,11 +1961,18 @@ tree: watch the agent work, review fast, accept or reject safely. - ☑ Close/reopen the PR (`repo_pull_request_lifecycle`; GitHub `gh pr`, Azure Services `az repos pr update`, and Azure Server helper protocol v2 `Operation::SetStatus`; keyboard-operable confirmed overflow action). - - ☐ 1.1: GitLab merge-request adapter. - - ☐ 1.1: Bitbucket Cloud pull-request adapter; scope Bitbucket Server separately. - - ☐ **F11 / P2 — GitHub enterprise/custom-host adapter.** Model host/API/auth + - ☑ 1.1: GitLab merge-request adapter (`HostedRepo`, paged collections, + versioned inline coordinates, approvals, lifecycle and SHA-guarded merge). + - ☑ 1.1: Bitbucket Cloud pull-request adapter (`HostedRepo`, opaque pagination, + replies/ranges, approval/request-changes and capability-gated actions). + - ☐ Bitbucket Server adapter; Cloud merge remains a provider-site action + until an atomic expected-head guard is available. GitLab request-changes + and Bitbucket discussion resolution/draft transitions also remain + provider-site actions. + - ☑ **F11 / P2 — GitHub enterprise/custom-host adapter.** Model host/API/auth scope instead of hardcoding GitHub.com; keep the GitLab/Bitbucket adapter - rows above as the other F11 deliverables. + rows above as the other F11 deliverables (`GitHubContext`, host-scoped CLI + routing and per-remote adapter selection in Settings → Hosting). - ☐ 1.1: Direct OAuth + OS-keychain credentials if/when Strand stops delegating auth to provider CLIs (blocked on Platform → per-platform credential storage). diff --git a/crates/strand-azdo-protocol/src/lib.rs b/crates/strand-azdo-protocol/src/lib.rs index 79591964..34e53464 100644 --- a/crates/strand-azdo-protocol/src/lib.rs +++ b/crates/strand-azdo-protocol/src/lib.rs @@ -7,7 +7,7 @@ use serde_json::Value; use url::Url; use uuid::Uuid; -pub const PROTOCOL_VERSION: u32 = 6; +pub const PROTOCOL_VERSION: u32 = 7; pub const MAX_REQUEST_BYTES: usize = 128 * 1024; pub const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; @@ -175,6 +175,15 @@ pub enum Operation { reviewer_id: String, vote: ReviewVote, }, + SetAutoComplete { + project: String, + repository: String, + id: u64, + enabled: bool, + viewer_id: String, + expected_head: String, + strategy: MergeStrategy, + }, Complete { project: String, repository: String, diff --git a/crates/strand-azdo/src/client.rs b/crates/strand-azdo/src/client.rs index d5c57a06..a56f8d2e 100644 --- a/crates/strand-azdo/src/client.rs +++ b/crates/strand-azdo/src/client.rs @@ -441,6 +441,20 @@ fn request_spec(operation: Operation) -> RequestSpec { })), unwrap_value: false, }, + Operation::SetAutoComplete { project, repository, id, enabled, viewer_id, expected_head, strategy } => RequestSpec { + method: Method::PATCH, + path: git_path(&project, &repository, &format!("pullrequests/{id}")), + query: api(), + body: Some(if enabled { json!({ + "autoCompleteSetBy": {"id": viewer_id}, + "lastMergeSourceCommit": {"commitId": expected_head}, + "completionOptions": { + "mergeStrategy": match strategy { MergeStrategy::MergeCommit => "noFastForward", MergeStrategy::Squash => "squash", MergeStrategy::Rebase => "rebase" }, + "deleteSourceBranch": false, "transitionWorkItems": false, "bypassPolicy": false + } + }) } else { json!({"autoCompleteSetBy":{"id":"00000000-0000-0000-0000-000000000000"}}) }), + unwrap_value: false, + }, Operation::Complete { project, repository, @@ -622,6 +636,23 @@ mod tests { }; use uuid::Uuid; + #[test] + fn auto_complete_enable_and_cancel_have_distinct_payloads() { + let operation = |enabled| Operation::SetAutoComplete { + project: "project".into(), repository: "repo".into(), id: 7, + enabled, viewer_id: "viewer".into(), expected_head: "a".repeat(40), strategy: MergeStrategy::Squash, + }; + let enable = request_spec(operation(true)); + let body = enable.body.unwrap(); + assert_eq!(body["autoCompleteSetBy"]["id"], "viewer"); + assert_eq!(body["lastMergeSourceCommit"]["commitId"], "a".repeat(40)); + assert_eq!(body["completionOptions"]["bypassPolicy"], false); + assert!(body.get("status").is_none()); + let cancel = request_spec(operation(false)).body.unwrap(); + assert_eq!(cancel["autoCompleteSetBy"]["id"], "00000000-0000-0000-0000-000000000000"); + assert!(cancel.get("completionOptions").is_none()); + } + #[test] fn list_route_is_escaped_bounded_and_shallow() { let spec = request_spec(Operation::ListPullRequests { diff --git a/crates/strand-core/Cargo.toml b/crates/strand-core/Cargo.toml index ac677e40..ce0521bc 100644 --- a/crates/strand-core/Cargo.toml +++ b/crates/strand-core/Cargo.toml @@ -13,3 +13,9 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true + +[dev-dependencies] +tempfile = "3" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/strand-core/src/advanced_refs.rs b/crates/strand-core/src/advanced_refs.rs new file mode 100644 index 00000000..698884eb --- /dev/null +++ b/crates/strand-core/src/advanced_refs.rs @@ -0,0 +1,667 @@ +//! Lazy Git notes/replacements and explicit, compare-and-swap tag editing. +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; + +const LIMIT: usize = 2000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectSummary { + pub oid: String, + pub kind: String, + pub subject: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interchange::InterchangeScratch; + fn git(path: &std::path::Path, args: &[&str]) -> String { + let out = crate::git_command() + .current_dir(path) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{:?}: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().into() + } + fn fixture() -> (InterchangeScratch, Repo, Vec) { + let scratch = InterchangeScratch::new().unwrap(); + let raw = git2::Repository::init(&scratch.0).unwrap(); + let mut config = raw.config().unwrap(); + config.set_str("user.name", "Refs Tester").unwrap(); + config.set_str("user.email", "refs@example.test").unwrap(); + config.set_bool("commit.gpgsign", false).unwrap(); + config.set_bool("tag.gpgsign", false).unwrap(); + config.set_str("core.hooksPath", "/dev/null").unwrap(); + let mut ids = vec![]; + for i in 0..3 { + std::fs::write(scratch.0.join("file"), format!("{i}\n")).unwrap(); + git(&scratch.0, &["add", "."]); + git(&scratch.0, &["commit", "-m", &format!("step {i}")]); + ids.push(git(&scratch.0, &["rev-parse", "HEAD"])); + } + let repo = Repo::discover(&scratch.0).unwrap(); + (scratch, repo, ids) + } + #[test] + fn notes_preserve_namespaces_external_edits_and_worktree_sharing() { + let (scratch, repo, ids) = fixture(); + let ns = "refs/notes/commits"; + repo.write_git_note(ns, &ids[0], None, Some("first note")) + .unwrap(); + let note = repo.git_note(ns, &ids[0]).unwrap(); + assert_eq!(note.message.as_deref(), Some("first note")); + git( + &repo.path, + &["notes", "add", "-m", "external note", &ids[1]], + ); + assert!(repo + .write_git_note(ns, &ids[0], note.ref_tip.as_deref(), Some("stale")) + .is_err()); + assert_eq!( + repo.git_note(ns, &ids[1]).unwrap().message.as_deref(), + Some("external note\n") + ); + let fresh = repo.git_note(ns, &ids[0]).unwrap(); + repo.write_git_note(ns, &ids[0], fresh.ref_tip.as_deref(), Some("edited")) + .unwrap(); + repo.write_git_note("refs/notes/other", &ids[0], None, Some("separate")) + .unwrap(); + let link = scratch.0.join("linked"); + git( + &repo.path, + &["worktree", "add", "-b", "linked", link.to_str().unwrap()], + ); + let linked = Repo::discover(&link).unwrap(); + let fresh = linked.git_note(ns, &ids[0]).unwrap(); + assert_eq!(fresh.message.as_deref(), Some("edited")); + linked + .write_git_note(ns, &ids[0], fresh.ref_tip.as_deref(), None) + .unwrap(); + assert!(repo.git_note(ns, &ids[0]).unwrap().message.is_none()); + assert_eq!(repo.advanced_refs(ns).unwrap().notes.len(), 1); + assert_eq!( + repo.git_note("refs/notes/other", &ids[0]) + .unwrap() + .message + .as_deref(), + Some("separate") + ); + assert!(git(&repo.path, &["for-each-ref", "refs/strand/notes-"]).is_empty()); + } + #[test] + fn replacements_reject_cycles_type_mismatch_and_stale_writes() { + let (_s, repo, ids) = fixture(); + repo.write_replacement(&ids[0], Some(&ids[1]), None) + .unwrap(); + assert_eq!( + repo.review_replacement(&ids[0], &ids[2]) + .unwrap() + .original + .oid, + ids[0] + ); + assert!(repo.review_replacement(&ids[1], &ids[0]).is_err()); + assert!(repo.review_replacement(&ids[0], "HEAD:file").is_err()); + assert!(repo + .write_replacement(&ids[0], Some(&ids[2]), None) + .is_err()); + repo.write_replacement(&ids[0], Some(&ids[2]), Some(&ids[1])) + .unwrap(); + assert_eq!( + git( + &repo.path, + &["--no-replace-objects", "show", "-s", "--format=%s", &ids[0]] + ), + "step 0" + ); + assert!(repo + .write_replacement(&ids[0], None, Some(&ids[1])) + .is_err()); + repo.write_replacement(&ids[0], None, Some(&ids[2])) + .unwrap(); + assert!(repo + .advanced_refs("refs/notes/commits") + .unwrap() + .replacements + .is_empty()); + } + #[test] + fn tag_edit_preserves_kind_annotation_and_detects_publication_and_staleness() { + let (scratch, repo, ids) = fixture(); + git(&repo.path, &["tag", "light", &ids[0]]); + repo.edit_tag("light", &ids[1], &ids[0], TagEditKind::Retarget, None) + .unwrap(); + assert_eq!( + git(&repo.path, &["cat-file", "-t", "refs/tags/light"]), + "commit" + ); + assert!(repo + .edit_tag("light", &ids[2], &ids[0], TagEditKind::Retarget, None) + .is_err()); + git( + &repo.path, + &["tag", "-a", "annotated", "-m", "preserve me", &ids[0]], + ); + let review = repo.review_tag_edit("annotated", &ids[1]).unwrap(); + assert_eq!(review.changed_files, 1); + repo.edit_tag( + "annotated", + &ids[1], + &review.ref_oid, + TagEditKind::Retarget, + None, + ) + .unwrap(); + let review = repo.review_tag_edit("annotated", &ids[1]).unwrap(); + assert_eq!(review.annotation.as_deref(), Some("preserve me\n")); + assert!(repo + .edit_tag( + "annotated", + &ids[2], + &review.ref_oid, + TagEditKind::Reannotate, + Some("new") + ) + .is_err()); + repo.edit_tag( + "annotated", + &ids[1], + &review.ref_oid, + TagEditKind::Reannotate, + Some("new annotation"), + ) + .unwrap(); + let bare = scratch.0.join("remote.git"); + git2::Repository::init_bare(&bare).unwrap(); + git( + &repo.path, + &["remote", "add", "origin", bare.to_str().unwrap()], + ); + assert!(repo + .published_tag("origin", "annotated") + .unwrap() + .oid + .is_none()); + git(&repo.path, &["push", "origin", "refs/tags/annotated"]); + let review = repo.review_tag_edit("annotated", &ids[2]).unwrap(); + assert_eq!( + repo.published_tag("origin", "annotated") + .unwrap() + .oid + .as_deref(), + Some(review.ref_oid.as_str()) + ); + repo.edit_tag( + "annotated", + &ids[2], + &review.ref_oid, + TagEditKind::Retarget, + None, + ) + .unwrap(); + assert_ne!( + repo.published_tag("origin", "annotated").unwrap().oid, + Some(repo.review_tag_edit("annotated", &ids[2]).unwrap().ref_oid) + ); + let raw = repo.git2_owned().unwrap(); + raw.tag( + "signed", + &raw.find_object(git2::Oid::from_str(&ids[0]).unwrap(), None) + .unwrap(), + &raw.signature().unwrap(), + "signed\n-----BEGIN SSH SIGNATURE-----\nfixture", + false, + ) + .unwrap(); + let signed = repo.review_tag_edit("signed", &ids[1]).unwrap(); + assert!(signed.signed); + assert!(repo + .edit_tag( + "signed", + &ids[1], + &signed.ref_oid, + TagEditKind::Retarget, + None + ) + .is_err()); + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteEntry { + pub object: String, + pub note: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplaceEntry { + pub original: String, + pub replacement: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdvancedRefs { + pub notes_refs: Vec, + pub notes_tip: Option, + pub notes: Vec, + pub notes_truncated: bool, + pub replacements: Vec, + pub replacements_truncated: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitNote { + pub target: ObjectSummary, + pub ref_tip: Option, + pub message: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplaceReview { + pub original: ObjectSummary, + pub replacement: ObjectSummary, + pub previous: Option, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TagEditKind { + Retarget, + Reannotate, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TagEditReview { + pub name: String, + pub ref_oid: String, + pub current: ObjectSummary, + pub proposed: ObjectSummary, + pub annotation: Option, + pub signed: bool, + pub changed_files: usize, + pub remotes: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishedTag { + pub remote: String, + pub oid: Option, +} + +fn notes_name(name: &str) -> Result<()> { + if !name.starts_with("refs/notes/") || !git2::Reference::is_valid_name(name) { + return Err(Error::Other( + "use a full notes ref such as refs/notes/commits".into(), + )); + } + Ok(()) +} +fn ref_tip(repo: &git2::Repository, name: &str) -> Result> { + match repo.find_reference(name) { + Ok(r) => r + .target() + .map(|o| Some(o.to_string())) + .ok_or_else(|| Error::Other("symbolic refs cannot be edited here".into())), + Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} +fn summary(object: &git2::Object<'_>) -> ObjectSummary { + ObjectSummary { + oid: object.id().to_string(), + kind: object.kind().map(|k| k.str()).unwrap_or("unknown").into(), + subject: object + .as_commit() + .and_then(|c| c.summary()) + .or_else(|| object.as_tag().and_then(|t| t.name())) + .unwrap_or("") + .into(), + } +} +fn expect_tip(repo: &git2::Repository, name: &str, expected: Option<&str>) -> Result<()> { + if ref_tip(repo, name)?.as_deref() != expected { + return Err(Error::Other( + "reference changed externally; inspect it again before editing".into(), + )); + } + Ok(()) +} + +impl Repo { + pub fn advanced_refs(&self, notes_ref: &str) -> Result { + notes_name(notes_ref)?; + let repo = self.git2_owned()?; + let mut notes_refs = vec![]; + for reference in repo.references_glob("refs/notes/*")? { + if let Some(name) = reference?.name() { + notes_refs.push(name.to_owned()); + } + if notes_refs.len() >= LIMIT { + break; + } + } + let notes_tip = ref_tip(&repo, notes_ref)?; + let mut notes = vec![]; + if notes_tip.is_some() { + for entry in repo.notes(Some(notes_ref))?.take(LIMIT + 1) { + let (note, object) = entry?; + notes.push(NoteEntry { + object: object.to_string(), + note: note.to_string(), + }); + } + } + let notes_truncated = notes.len() > LIMIT; + notes.truncate(LIMIT); + let mut replacements = vec![]; + for reference in repo.references_glob("refs/replace/*")?.take(LIMIT + 1) { + let reference = reference?; + if let (Some(original), Some(target)) = ( + reference + .name() + .and_then(|n| n.strip_prefix("refs/replace/")), + reference.target(), + ) { + replacements.push(ReplaceEntry { + original: original.to_owned(), + replacement: target.to_string(), + }); + } + } + let replacements_truncated = replacements.len() > LIMIT; + replacements.truncate(LIMIT); + Ok(AdvancedRefs { + notes_refs, + notes_tip, + notes, + notes_truncated, + replacements, + replacements_truncated, + }) + } + + pub fn git_note(&self, notes_ref: &str, revision: &str) -> Result { + notes_name(notes_ref)?; + let repo = self.git2_owned()?; + let target = repo.revparse_single(revision)?; + let tip = ref_tip(&repo, notes_ref)?; + let message = match repo.find_note(Some(notes_ref), target.id()) { + Ok(note) => { + if note.message_bytes().len() > 1024 * 1024 { + return Err(Error::Other("note exceeds the 1 MiB editing limit".into())); + } + Some( + note.message() + .ok_or_else(|| { + Error::Other("this note is not UTF-8 and cannot be edited here".into()) + })? + .to_owned(), + ) + } + Err(e) if e.code() == git2::ErrorCode::NotFound => None, + Err(e) => return Err(e.into()), + }; + Ok(GitNote { + target: summary(&target), + ref_tip: tip, + message, + }) + } + + pub fn write_git_note( + &self, + notes_ref: &str, + object: &str, + expected: Option<&str>, + message: Option<&str>, + ) -> Result<()> { + notes_name(notes_ref)?; + if message.is_some_and(|m| m.trim().is_empty() || m.len() > 1024 * 1024) { + return Err(Error::Other( + "notes must contain text and be at most 1 MiB; use Remove to delete a note".into(), + )); + } + let repo = self.git2_owned()?; + let object = git2::Oid::from_str(object)?; + repo.find_object(object, None)?; + let mut transaction = repo.transaction()?; + transaction.lock_ref(notes_ref)?; + expect_tip(&repo, notes_ref, expected)?; + // libgit2's notes writer publishes a ref. Write through a private temporary + // ref, then publish its commit to the locked real namespace. External notes + // updates cannot be lost between reading the tree and replacing its tip. + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let temporary = format!( + "refs/strand/notes-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| Error::Other(e.to_string()))? + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + if ref_tip(&repo, &temporary)?.is_some() { + return Err(Error::Other("temporary notes ref already exists".into())); + } + let signature = repo.signature()?; + if let Some(tip) = expected { + repo.reference( + &temporary, + git2::Oid::from_str(tip)?, + false, + "Strand notes preparation", + )?; + } + let result = (|| -> Result<()> { + match message { + Some(text) => { + repo.note(&signature, &signature, Some(&temporary), object, text, true)?; + } + None => repo.note_delete(object, Some(&temporary), &signature, &signature)?, + } + let tip = repo + .find_reference(&temporary)? + .target() + .ok_or_else(|| Error::Other("notes writer returned a symbolic ref".into()))?; + transaction.set_target(notes_ref, tip, Some(&signature), "Strand edit Git note")?; + transaction.commit()?; + Ok(()) + })(); + if let Ok(mut reference) = repo.find_reference(&temporary) { + let _ = reference.delete(); + } + result + } + + pub fn review_replacement(&self, original: &str, replacement: &str) -> Result { + let repo = self.git2_owned()?; + let old = repo.revparse_single(original)?; + let new = repo.revparse_single(replacement)?; + if old.kind() != new.kind() { + return Err(Error::Other( + "replacement objects must have the same Git object type".into(), + )); + } + let mut next = new.id(); + let mut visited = std::collections::HashSet::new(); + loop { + if next == old.id() || !visited.insert(next) { + return Err(Error::Other("replacement would create a cycle".into())); + } + let Some(target) = ref_tip(&repo, &format!("refs/replace/{next}"))? else { + break; + }; + next = git2::Oid::from_str(&target)?; + if visited.len() >= 5 { + return Err(Error::Other( + "replacement chain exceeds Git's supported depth".into(), + )); + } + } + Ok(ReplaceReview { + original: summary(&old), + replacement: summary(&new), + previous: ref_tip(&repo, &format!("refs/replace/{}", old.id()))?, + }) + } + + pub fn write_replacement( + &self, + original: &str, + replacement: Option<&str>, + expected: Option<&str>, + ) -> Result<()> { + let original = git2::Oid::from_str(original)?.to_string(); + let repo = self.git2_owned()?; + let refname = format!("refs/replace/{original}"); + let mut transaction = repo.transaction()?; + transaction.lock_ref(&refname)?; + expect_tip(&repo, &refname, expected)?; + if let Some(replacement) = replacement { + let review = self.review_replacement(&original, replacement)?; + let target = git2::Oid::from_str(&review.replacement.oid)?; + transaction.set_target(&refname, target, None, "Strand edit replacement")?; + } else { + if expected.is_none() { + return Err(Error::Other("no replacement exists for this object".into())); + } + transaction.remove(&refname)?; + } + transaction.commit()?; + Ok(()) + } + + pub fn review_tag_edit(&self, name: &str, target: &str) -> Result { + let repo = self.git2_owned()?; + let refname = format!("refs/tags/{name}"); + if !git2::Reference::is_valid_name(&refname) { + return Err(Error::Other("invalid tag name".into())); + } + let ref_oid = + ref_tip(&repo, &refname)?.ok_or_else(|| Error::Other("tag no longer exists".into()))?; + let raw = repo.find_object(git2::Oid::from_str(&ref_oid)?, None)?; + let current = raw.peel_to_commit()?; + let proposed = repo.revparse_single(target)?.peel_to_commit()?; + let annotation = if let Some(tag) = raw.as_tag() { + let message = tag + .message() + .ok_or_else(|| Error::Other("tag annotation is not UTF-8".into()))?; + if message.len() > 1024 * 1024 { + return Err(Error::Other("annotation exceeds 1 MiB".into())); + } + Some(message.to_owned()) + } else { + None + }; + let signed = annotation.as_deref().is_some_and(|m| { + [ + "-----BEGIN PGP SIGNATURE-----", + "-----BEGIN SSH SIGNATURE-----", + "-----BEGIN SIGNED MESSAGE-----", + ] + .iter() + .any(|marker| m.contains(marker)) + }); + let changed_files = repo + .diff_tree_to_tree(Some(¤t.tree()?), Some(&proposed.tree()?), None)? + .deltas() + .len(); + let remotes = repo + .remotes()? + .iter() + .flatten() + .map(str::to_owned) + .collect(); + Ok(TagEditReview { + name: name.into(), + ref_oid, + current: summary(current.as_object()), + proposed: summary(proposed.as_object()), + annotation, + signed, + changed_files, + remotes, + }) + } + + pub fn edit_tag( + &self, + name: &str, + target: &str, + expected: &str, + kind: TagEditKind, + message: Option<&str>, + ) -> Result<()> { + let review = self.review_tag_edit(name, target)?; + if review.ref_oid != expected { + return Err(Error::Other( + "tag changed externally; review the targets again".into(), + )); + } + if review.signed { + return Err(Error::Other( + "editing this signed tag requires a new signature; use the signed-tag workflow" + .into(), + )); + } + if kind == TagEditKind::Reannotate && review.current.oid != review.proposed.oid { + return Err(Error::Other( + "re-annotation must keep the current target; use Retarget for a different commit" + .into(), + )); + } + let repo = self.git2_owned()?; + let object = repo.find_object(git2::Oid::from_str(&review.proposed.oid)?, None)?; + let annotation = if kind == TagEditKind::Reannotate { + let text = message + .filter(|m| !m.trim().is_empty()) + .ok_or_else(|| Error::Other("an annotation is required".into()))?; + if text.len() > 1024 * 1024 { + return Err(Error::Other("annotation exceeds 1 MiB".into())); + } + Some(text) + } else { + review.annotation.as_deref() + }; + let oid = if let Some(annotation) = annotation { + repo.tag_annotation_create(name, &object, &repo.signature()?, annotation)? + } else { + object.id() + }; + repo.reference_matching( + &format!("refs/tags/{name}"), + oid, + true, + git2::Oid::from_str(expected)?, + "Strand edit tag", + )?; + Ok(()) + } + + pub fn published_tag(&self, remote: &str, name: &str) -> Result { + let repo = self.git2_owned()?; + repo.find_remote(remote)?; + let refname = format!("refs/tags/{name}"); + if remote.starts_with('-') || !git2::Reference::is_valid_name(&refname) { + return Err(Error::Other("invalid remote or tag name".into())); + } + let result = crate::network::run_git_streaming_transcript( + &self.path, + &["ls-remote", "--refs", "--tags", "--", remote, &refname], + |_| {}, + None, + )?; + if !result.success { + return Err(Error::Other(result.output)); + } + let oid = result.output.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let oid = fields.next()?; + (fields.next()? == refname).then(|| oid.to_owned()) + }); + Ok(PublishedTag { + remote: remote.into(), + oid, + }) + } +} diff --git a/crates/strand-core/src/apply.rs b/crates/strand-core/src/apply.rs index f347a294..fef1f76b 100644 --- a/crates/strand-core/src/apply.rs +++ b/crates/strand-core/src/apply.rs @@ -36,6 +36,23 @@ impl Repo { ApplyTarget::Workdir => (patch.to_owned(), git2::ApplyLocation::WorkDir), }; let diff = git2::Diff::from_buffer(buf.as_bytes())?; + for delta in diff.deltas() { + for file in [delta.old_file(), delta.new_file()] { + if let Some(path) = file.path() { + if self.is_lfs_path(path)? { + return Err(crate::Error::Other("LFS files must be staged, unstaged or discarded as a whole file; partial patches would corrupt the pointer.".into())); + } + } + } + } + if self.sparse_enabled() { + let mut args = vec!["apply", "--whitespace=nowarn"]; + if matches!(target, ApplyTarget::Index | ApplyTarget::IndexReverse) { args.push("--cached"); } + if matches!(target, ApplyTarget::IndexReverse | ApplyTarget::WorkdirReverse) { args.push("--reverse"); } + args.push("-"); + self.sparse_git(&args, Some(patch.as_bytes()))?; + return Ok(()); + } repo.apply(&diff, location, None)?; Ok(()) } diff --git a/crates/strand-core/src/bisect.rs b/crates/strand-core/src/bisect.rs new file mode 100644 index 00000000..808cb3f6 --- /dev/null +++ b/crates/strand-core/src/bisect.rs @@ -0,0 +1,423 @@ +//! Manual bisect, driven by Git's worktree-local state (including external runs). +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; +use std::{fs, io::Read, path::Path, process::Stdio}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BisectState { + pub active: bool, + pub token: String, + pub original: String, + pub original_tip: String, + pub current: String, + pub subject: String, + pub expected: String, + pub good_term: String, + pub bad_term: String, + pub remaining: usize, + pub remaining_truncated: bool, + pub range_error: String, + pub culprit: Option, + pub ambiguous: bool, + pub no_checkout: bool, + pub clean: bool, + pub log: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BisectAction { + Good, + Bad, + Skip, + Reset, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BisectOutcome { + pub success: bool, + pub output: String, + pub state: BisectState, +} + +fn git(cwd: &Path, args: &[&str]) -> Result { + Ok(crate::git_command() + .current_dir(cwd) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .env("LC_ALL", "C") + .env("GIT_EDITOR", "true") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .output()?) +} +fn text(out: &std::process::Output) -> String { + String::from_utf8_lossy( + &out.stdout + .iter() + .chain(&out.stderr) + .take(65536) + .copied() + .collect::>(), + ) + .trim() + .to_owned() +} +fn checked(cwd: &Path, args: &[&str]) -> Result { + let out = git(cwd, args)?; + if !out.status.success() { + return Err(Error::Other(text(&out))); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} +fn state_file(dir: &Path, name: &str) -> Result { + let file = match fs::File::open(dir.join(name)) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(String::new()), + Err(e) => return Err(e.into()), + }; + let mut text = String::new(); + file.take(2 * 1024 * 1024 + 1).read_to_string(&mut text)?; + if text.len() > 2 * 1024 * 1024 { + return Err(Error::Other("bisect state is too large to inspect".into())); + } + Ok(text.trim().into()) +} + +impl Repo { + pub fn bisect_state(&self) -> Result { + let dir = self.git_dir(); + let active = dir.join("BISECT_START").exists(); + let original = state_file(dir, "BISECT_START")?; + let log = state_file(dir, "BISECT_LOG")?; + let expected = state_file(dir, "BISECT_EXPECTED_REV")?; + let terms = state_file(dir, "BISECT_TERMS")?; + let mut terms = terms.lines(); + let bad_term = terms.next().unwrap_or("bad").to_string(); + let good_term = terms.next().unwrap_or("good").to_string(); + let no_checkout = dir.join("BISECT_HEAD").exists(); + let fresh = self.git2_owned()?; + let original_tip = fresh + .find_reference(&format!("refs/heads/{original}")) + .and_then(|r| r.peel_to_commit()) + .or_else(|_| fresh.revparse_single(&original)?.peel_to_commit()) + .map(|c| c.id().to_string()) + .unwrap_or_default(); + let revision = if no_checkout { "BISECT_HEAD" } else { "HEAD" }; + let commit = fresh.revparse_single(revision)?.peel_to_commit()?; + let current = commit.id().to_string(); + let subject = commit.summary().unwrap_or("").to_owned(); + let status = checked( + &self.path, + &["status", "--porcelain", "--untracked-files=normal"], + )?; + let clean = status.is_empty(); + let candidates = if active { + checked( + &self.path, + &[ + "bisect", + "visualize", + "--format=%H", + "--no-patch", + "--max-count=10001", + ], + ) + } else { + Ok(String::new()) + }; + let (candidates, range_error) = match candidates { + Ok(value) => (value, String::new()), + Err(e) => (String::new(), e.to_string()), + }; + let remaining = candidates + .lines() + .filter(|l| l.len() == 40 || l.len() == 64) + .count(); + let culprit = log.lines().rev().find_map(|line| { + let prefix = format!("# first {bad_term} commit: ["); + let quoted_prefix = format!("# first '{bad_term}' commit: ["); + line.strip_prefix(&prefix) + .or_else(|| line.strip_prefix("ed_prefix)) + .and_then(|s| s.split_once(']')) + .map(|(oid, _)| oid.to_owned()) + }); + let ambiguous = + active && culprit.is_none() && log.contains("# only skipped commits left to test"); + let refs = checked( + &self.path, + &[ + "for-each-ref", + "--format=%(refname) %(objectname)", + "refs/bisect/", + ], + )?; + let stamp = format!("{active}:{original}:{original_tip}:{log}:{expected}:{current}:{refs}:{status}:{bad_term}:{good_term}:{}", state_file(dir, "HEAD")?); + let token = git2::Oid::hash_object(git2::ObjectType::Blob, stamp.as_bytes())?.to_string(); + Ok(BisectState { + active, + token, + original, + original_tip, + current, + subject, + expected, + good_term, + bad_term, + remaining: remaining.min(10000), + remaining_truncated: remaining > 10000, + range_error, + culprit, + ambiguous, + no_checkout, + clean, + log, + }) + } + + pub fn bisect_start(&self, good: &str, bad: &str, token: &str) -> Result { + let before = self.bisect_state()?; + if before.token != token { + return Err(Error::Other( + "repository changed; refresh the bisect review".into(), + )); + } + if before.active || self.operation_in_progress().is_some() { + return Err(Error::Other( + "finish the current Git operation before starting bisect".into(), + )); + } + if !before.clean { + return Err(Error::Other( + "commit or stash working-tree/index changes before bisect".into(), + )); + } + let fresh = self.git2_owned()?; + let good = fresh.revparse_single(good)?.peel_to_commit()?.id(); + let bad = fresh.revparse_single(bad)?.peel_to_commit()?.id(); + if good == bad || !fresh.graph_descendant_of(bad, good)? { + return Err(Error::Other( + "the good revision must be an earlier ancestor of the bad revision".into(), + )); + } + let out = git( + &self.path, + &["bisect", "start", &bad.to_string(), &good.to_string(), "--"], + )?; + Ok(BisectOutcome { + success: out.status.success(), + output: text(&out), + state: self.bisect_state()?, + }) + } + + pub fn bisect_action(&self, action: BisectAction, token: &str) -> Result { + let before = self.bisect_state()?; + if !before.active { + return Err(Error::Other("no bisect session is active".into())); + } + if before.token != token { + return Err(Error::Other( + "bisect changed externally; refresh before rating a revision".into(), + )); + } + if !before.clean { + return Err(Error::Other( + "commit or stash test edits before bisect changes the checkout".into(), + )); + } + if self + .operation_in_progress() + .is_some_and(|op| op != "bisect") + { + return Err(Error::Other( + "finish the other Git operation before continuing bisect".into(), + )); + } + let out = if matches!(action, BisectAction::Reset) { + // Never use checkout/reset --force. Git also protects the original + // branch if another worktree has checked it out while we were testing. + git(&self.path, &["bisect", "reset"])? + } else { + if before.culprit.is_some() || before.ambiguous { + return Err(Error::Other( + "bisect has finished; review the result and reset".into(), + )); + } + if !before.expected.is_empty() && before.current != before.expected { + return Err(Error::Other("HEAD no longer matches Git's bisect selection; restore that checkout before rating".into())); + } + let term = match action { + BisectAction::Good => &before.good_term, + BisectAction::Bad => &before.bad_term, + BisectAction::Skip => "skip", + BisectAction::Reset => unreachable!(), + }; + if term.starts_with('-') + || !term + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(Error::Other("unsupported external bisect term".into())); + } + git(&self.path, &["bisect", term, &before.current])? + }; + Ok(BisectOutcome { + success: out.status.success(), + output: text(&out), + state: self.bisect_state()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interchange::InterchangeScratch; + fn fixture() -> (InterchangeScratch, Repo, Vec) { + let scratch = InterchangeScratch::new().unwrap(); + let repo = git2::Repository::init(&scratch.0).unwrap(); + let mut cfg = repo.config().unwrap(); + cfg.set_str("user.name", "Bisect Tester").unwrap(); + cfg.set_str("user.email", "bisect@example.test").unwrap(); + cfg.set_bool("commit.gpgsign", false).unwrap(); + cfg.set_str("core.hooksPath", "/dev/null").unwrap(); + let mut commits = vec![]; + for n in 0..8 { + fs::write(scratch.0.join("number"), format!("{n}\n")).unwrap(); + checked(&scratch.0, &["add", "."]).unwrap(); + checked(&scratch.0, &["commit", "-m", &format!("step {n}")]).unwrap(); + commits.push(checked(&scratch.0, &["rev-parse", "HEAD"]).unwrap()); + } + let handle = Repo::discover(&scratch.0).unwrap(); + (scratch, handle, commits) + } + #[test] + fn finds_culprit_and_restores_original_branch() { + let (_s, repo, commits) = fixture(); + let original = checked(&repo.path, &["symbolic-ref", "HEAD"]).unwrap(); + let mut state = repo + .bisect_start( + &commits[0], + &commits[7], + &repo.bisect_state().unwrap().token, + ) + .unwrap() + .state; + assert!(state.active && state.remaining > 0); + for _ in 0..10 { + if state.culprit.is_some() { + break; + } + let n = commits.iter().position(|c| c == &state.current).unwrap(); + state = repo + .bisect_action( + if n >= 4 { + BisectAction::Bad + } else { + BisectAction::Good + }, + &state.token, + ) + .unwrap() + .state; + } + assert_eq!(state.culprit, Some(commits[4].clone()), "{}", state.log); + assert!( + !repo + .bisect_action(BisectAction::Reset, &state.token) + .unwrap() + .state + .active + ); + assert_eq!( + checked(&repo.path, &["symbolic-ref", "HEAD"]).unwrap(), + original + ); + assert_eq!( + checked(&repo.path, &["rev-parse", "HEAD"]).unwrap(), + commits[7] + ); + } + #[test] + fn external_session_stale_ratings_dirty_reset_and_skips() { + let (_s, repo, commits) = fixture(); + checked( + &repo.path, + &["bisect", "start", &commits[7], &commits[0], "--"], + ) + .unwrap(); + let state = repo.bisect_state().unwrap(); + assert!(state.active); + checked(&repo.path, &["bisect", "good"]).unwrap(); + assert!(repo.bisect_action(BisectAction::Bad, &state.token).is_err()); + let fresh = repo.bisect_state().unwrap(); + fs::write(repo.path.join("number"), "test edits\n").unwrap(); + assert!(repo + .bisect_action(BisectAction::Reset, &fresh.token) + .is_err()); + assert_eq!( + fs::read_to_string(repo.path.join("number")).unwrap(), + "test edits\n" + ); + checked(&repo.path, &["restore", "number"]).unwrap(); + let mut state = repo.bisect_state().unwrap(); + for _ in 0..10 { + if state.ambiguous || state.culprit.is_some() { + break; + } + state = repo + .bisect_action(BisectAction::Skip, &state.token) + .unwrap() + .state; + } + assert!(state.ambiguous, "{}", state.log); + assert!( + repo.bisect_action(BisectAction::Reset, &state.token) + .unwrap() + .success + ); + } + #[test] + fn linked_worktree_and_external_no_checkout_terms() { + let (scratch, repo, commits) = fixture(); + let link = scratch.0.join("linked"); + checked( + &repo.path, + &["worktree", "add", "-b", "linked", link.to_str().unwrap()], + ) + .unwrap(); + let linked = Repo::discover(&link).unwrap(); + checked( + &link, + &[ + "bisect", + "start", + "--no-checkout", + "--term-good=old", + "--term-bad=new", + &commits[7], + &commits[0], + "--", + ], + ) + .unwrap(); + let state = linked.bisect_state().unwrap(); + assert!(state.active && state.no_checkout); + assert!(!repo.bisect_state().unwrap().active); + assert_eq!(state.good_term, "old"); + assert!( + linked + .bisect_action(BisectAction::Good, &state.token) + .unwrap() + .success + ); + let state = linked.bisect_state().unwrap(); + assert!( + linked + .bisect_action(BisectAction::Reset, &state.token) + .unwrap() + .success + ); + } +} diff --git a/crates/strand-core/src/blame.rs b/crates/strand-core/src/blame.rs index 734f70f2..4af41573 100644 --- a/crates/strand-core/src/blame.rs +++ b/crates/strand-core/src/blame.rs @@ -57,9 +57,7 @@ impl Repo { let entry = tree .get_path(Path::new(rel_path)) .map_err(|_| Error::Other(format!("{rel_path} is not tracked at HEAD")))?; - let blob = repo - .find_blob(entry.id()) - .map_err(|_| Error::Other(format!("{rel_path} is not a file")))?; + let blob = self.find_blob(entry.id())?; if blob.is_binary() { return Err(Error::Other(format!("{rel_path} is binary — no blame"))); } @@ -72,6 +70,9 @@ impl Repo { ))); } + if self.is_partial_clone() || repo.is_shallow() { + return self.blame_with_git(rel_path); + } let mut opts = git2::BlameOptions::new(); let blame = repo.blame_file(Path::new(rel_path), Some(&mut opts))?; @@ -123,6 +124,42 @@ impl Repo { } Ok(out) } + + /// Git understands shallow boundaries and can fetch promised blobs while + /// walking history. Keep that work on demand, after the size/binary gate. + fn blame_with_git(&self, path: &str) -> Result> { + let output = crate::git_command().current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0").args(crate::GIT_SAFE_CONFIG) + .args(["blame", "--line-porcelain", "HEAD", "--", path]).output()?; + if !output.status.success() { + return Err(Error::Other(String::from_utf8_lossy(&output.stderr).trim().into())); + } + let mut result = Vec::new(); + let mut current: Option = None; + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some(content) = line.strip_prefix('\t') { + if let Some(mut entry) = current.take() { + entry.content = content.to_owned(); + result.push(entry); + } + continue; + } + let fields: Vec<_> = line.split(' ').collect(); + if fields.len() >= 3 && fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()) { + current = Some(BlameLine { + line_no: fields[2].parse().map_err(|_| Error::Other("Invalid Git blame line".into()))?, + commit: fields[0].into(), short: fields[0][..7].into(), content: String::new(), + author: String::new(), author_email: String::new(), time_unix: 0, summary: String::new(), + }); + } else if let Some(entry) = &mut current { + if let Some(value) = line.strip_prefix("author ") { entry.author = value.into(); } + else if let Some(value) = line.strip_prefix("author-mail ") { entry.author_email = value.trim_start_matches('<').trim_end_matches('>').into(); } + else if let Some(value) = line.strip_prefix("author-time ") { entry.time_unix = value.parse().map_err(|_| Error::Other("Invalid Git blame time".into()))?; } + else if let Some(value) = line.strip_prefix("summary ") { entry.summary = value.into(); } + } + } + Ok(result) + } } #[cfg(test)] diff --git a/crates/strand-core/src/branch.rs b/crates/strand-core/src/branch.rs index 4c4fe7ee..d9be3fc7 100644 --- a/crates/strand-core/src/branch.rs +++ b/crates/strand-core/src/branch.rs @@ -30,6 +30,11 @@ impl Repo { /// check goes through the worktree registry; a registry read failure /// falls through (the rollback below still protects the repo). pub fn checkout_branch(&self, name: &str) -> Result { + if self.sparse_enabled() || self.is_partial_clone() { + self.git2()?.find_branch(name, git2::BranchType::Local)?; + crate::network::run_git_streaming(&self.path, &["switch", "--", name], |_| {}, None)?; + return Ok(CheckoutOutcome { branch: name.into() }); + } if let Some(wt) = self .worktrees() .unwrap_or_default() @@ -55,6 +60,10 @@ impl Repo { let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok()); let tree = branch.get().peel_to_tree()?; + if self.lfs_checkout_needed(&tree)? { + self.run_lfs_filtered(&["checkout", name, "--"])?; + return Ok(CheckoutOutcome { branch: name.to_string() }); + } let mut opts = git2::build::CheckoutBuilder::new(); opts.safe(); repo.checkout_tree(tree.as_object(), Some(&mut opts))?; @@ -152,7 +161,18 @@ impl Repo { let repo = self.git2()?; let commit = repo.revparse_single(rev)?.peel_to_commit()?; + if self.sparse_enabled() || self.is_partial_clone() { + let oid = commit.id().to_string(); + crate::network::run_git_streaming(&self.path, &["switch", "--detach", &oid], |_| {}, None)?; + return Ok(CheckoutOutcome { branch: oid[..7].into() }); + } + let tree = commit.tree()?; + if self.lfs_checkout_needed(&tree)? { + let oid = commit.id().to_string(); + self.run_lfs_filtered(&["checkout", "--detach", &oid, "--"])?; + return Ok(CheckoutOutcome { branch: oid[..7].to_string() }); + } let mut opts = git2::build::CheckoutBuilder::new(); opts.safe(); repo.checkout_tree(tree.as_object(), Some(&mut opts))?; diff --git a/crates/strand-core/src/commit.rs b/crates/strand-core/src/commit.rs index d99081c7..dcb0ea73 100644 --- a/crates/strand-core/src/commit.rs +++ b/crates/strand-core/src/commit.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{ error::{Error, Result}, repo::Repo, + signing::SigningMode, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -12,94 +13,41 @@ pub struct CommitOutcome { pub oid: String, /// Whether this commit was an amend of the previous HEAD. pub amended: bool, + /// Bounded stdout and stderr, including successful hook diagnostics. + pub output: String, } -/// Whether the repo's effective config asks for signed commits -/// (`commit.gpgSign = true`). Read through a snapshot for a consistent merged -/// view (system + global + local), like [`gitconfig`](crate::gitconfig); git2 -/// config keys are case-insensitive, so the lowercase lookup matches any -/// spelling. -fn signing_enabled(repo: &git2::Repository) -> bool { - repo.config() - .and_then(|mut c| c.snapshot()) - .and_then(|s| s.get_bool("commit.gpgsign")) - .unwrap_or(false) -} - -/// Write the current index as a new commit on HEAD. -/// -/// Two paths: when `commit.gpgSign` is off (the default) we commit in-process -/// via git2; when it's on we shell out to the user's `git` instead, because -/// git2 never signs — the shell-out picks up the user's gpg/ssh signing -/// config, `gpg.format`, and key lookup for free. +/// Commit/amend always use system Git: it owns hooks (including hooksPath), +/// identity, merge parents, signing and message rewrites. Index edits stay on +/// git2. This deliberately supersedes the old unsigned git2 fast path. impl Repo { pub fn commit(&self, subject: &str, body: Option<&str>, amend: bool) -> Result { - let repo = self.git2()?; + self.commit_with_signing(subject, body, amend, SigningMode::Inherit) + } + pub fn commit_with_signing(&self, subject: &str, body: Option<&str>, amend: bool, signing: SigningMode) -> Result { let message = match body.map(str::trim).filter(|b| !b.is_empty()) { Some(b) => format!("{}\n\n{}\n", subject.trim(), b), None => format!("{}\n", subject.trim()), }; - - let oid = if signing_enabled(repo) { - self.commit_via_git(&message, amend)?; - repo.head()?.peel_to_commit()?.id() - } else { - let sig = repo.signature()?; - let mut index = repo.index()?; - let tree_oid = index.write_tree()?; - let tree = repo.find_tree(tree_oid)?; - - if amend { - let head = repo.head()?; - let head_commit = head.peel_to_commit()?; - // Author `None` keeps the original author (git2 reuses the - // existing field), matching real `git commit --amend` and the - // shell-out path; only the committer is the current user. - head_commit.amend( - Some("HEAD"), - None, - Some(&sig), - None, - Some(&message), - Some(&tree), - )? - } else { - // Parent list: HEAD if it exists; empty for the initial commit. - let parents: Vec = match repo.head() { - Ok(h) => vec![h.peel_to_commit()?], - Err(_) => Vec::new(), - }; - let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs)? - } - }; - - Ok(CommitOutcome { - oid: oid.to_string(), - amended: amend, - }) + let output = self.commit_via_git(&message, amend, signing)?; + let oid = self.git2()?.head()?.peel_to_commit()?.id().to_string(); + Ok(CommitOutcome { oid, amended: amend, output }) } - /// Commit the staged index by shelling out to the user's `git` — the - /// signing path, since git2 cannot sign. The message goes through a temp - /// file (`-F`, with `--cleanup=verbatim`: the file is built by us and - /// already exact, so verbatim keeps `#` lines AND byte parity with the - /// git2 path, which never cleans) to dodge platform quoting. Unlike the - /// git2 path this runs the user's hooks (pre-commit / commit-msg) — that - /// matches plain `git commit` and is the same accepted trust boundary the - /// other shell-out ops have (PRD §10). - fn commit_via_git(&self, message: &str, amend: bool) -> Result<()> { + fn commit_via_git(&self, message: &str, amend: bool, signing: SigningMode) -> Result { let file = temp_message_file(message)?; let file_arg = file.to_string_lossy().into_owned(); let mut args = vec!["commit", "-F", file_arg.as_str(), "--cleanup=verbatim"]; - if amend { - args.push("--amend"); + if amend { args.push("--amend"); } + match signing { + SigningMode::Inherit => {}, + SigningMode::Sign => args.push("--gpg-sign"), + SigningMode::Unsigned => args.push("--no-gpg-sign"), } let res = run_git(&self.path, &args); - // Best effort, on the error path too — a leak here is only temp litter. let _ = std::fs::remove_file(&file); - res.map(|_| ()) + res } } @@ -109,7 +57,7 @@ impl Repo { /// refuses to open a path that already exists — including a pre-planted /// symlink in the shared temp dir (local TOCTOU) — so a collision just bumps /// the counter and retries (bounded). -fn temp_message_file(message: &str) -> Result { +pub(crate) fn temp_message_file(message: &str) -> Result { use std::io::Write; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -141,7 +89,7 @@ fn temp_message_file(message: &str) -> Result { /// (not a `Repo` method) so it doesn't collide with `stash`'s same-named /// helper on the same type. fn run_git(cwd: &Path, args: &[&str]) -> Result { - let out = crate::git_command() + let out = crate::git_output::capture(crate::git_command() .current_dir(cwd) .env("GIT_TERMINAL_PROMPT", "0") // Detach stdin so git can never block reading from a TTY/pipe we don't @@ -149,8 +97,8 @@ fn run_git(cwd: &Path, args: &[&str]) -> Result { .stdin(std::process::Stdio::null()) // Neutralize repo-local config that would run code as a side effect. .args(crate::GIT_SAFE_CONFIG) - .args(args) - .output() + .env("GIT_EDITOR", ":") + .args(args)) .map_err(|e| Error::Other(format!("spawn git failed: {e}")))?; if !out.status.success() { let stdout = String::from_utf8_lossy(&out.stdout); @@ -162,7 +110,7 @@ fn run_git(cwd: &Path, args: &[&str]) -> Result { combined })); } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + Ok(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).trim().to_string()) } #[cfg(test)] @@ -185,6 +133,8 @@ mod tests { git(&dir, &["init", "-q", "-b", "main"]); git(&dir, &["config", "user.name", "Test"]); git(&dir, &["config", "user.email", "test@example.com"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + git(&dir, &["config", "core.hooksPath", ".git/hooks"]); (Repo::discover(dir.to_str().unwrap()).unwrap(), dir) } @@ -204,18 +154,85 @@ mod tests { git(dir, &["add", file]); } + fn hook(dir: &Path, name: &str, script: &str) { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + #[test] - fn signing_enabled_defaults_off_and_follows_config() { + fn hooks_reject_rewrite_and_run_after_commit_and_amend() { let (repo, dir) = scratch_repo(); - let g2 = repo.git2().unwrap(); - assert!(!signing_enabled(&g2), "unset ⇒ off"); - - git(&dir, &["config", "commit.gpgsign", "true"]); - assert!(signing_enabled(&repo.git2().unwrap())); + stage(&dir, "a.txt", "a\n"); + let hooks = dir.join("custom hooks"); + git(&dir, &["config", "core.hooksPath", "custom hooks"]); + hook(&hooks, "pre-commit", "echo policy-rejected >&2; exit 1"); + let error = repo.commit("draft", Some("body"), false).unwrap_err().to_string(); + assert!(error.contains("policy-rejected")); + assert!(repo.git2().unwrap().head().is_err()); + assert_eq!(git(&dir, &["diff", "--cached", "--name-only"]), "a.txt"); + hook(&hooks, "pre-commit", "echo pre-commit-ok"); + hook(&hooks, "prepare-commit-msg", "echo prepared >> \"$1\""); + hook(&hooks, "commit-msg", "echo rewritten >> \"$1\""); + hook(&hooks, "post-commit", "echo post-commit-ok >&2"); + hook(&hooks, "post-rewrite", "read old new; echo post-rewrite-$1-$old-$new >&2"); + let outcome = repo.commit("draft", Some("body"), false).unwrap(); + assert!(outcome.output.contains("pre-commit-ok")); + assert!(outcome.output.contains("post-commit-ok")); + assert_eq!(git(&dir, &["log", "-1", "--format=%B"]), "draft\n\nbody\nprepared\nrewritten"); + hook(&hooks, "commit-msg", "echo message-rejected >&2; exit 1"); + assert!(repo.commit("amend draft", None, true).unwrap_err().to_string().contains("message-rejected")); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), outcome.oid); + hook(&hooks, "commit-msg", "echo amended >> \"$1\""); + let amended = repo.commit("amend draft", None, true).unwrap(); + assert!(amended.output.contains(&format!("post-rewrite-amend-{}-{}", outcome.oid, amended.oid))); + assert_eq!(git(&dir, &["rev-list", "--count", "HEAD"]), "1"); + let _ = std::fs::remove_dir_all(dir); + } - git(&dir, &["config", "commit.gpgsign", "false"]); - assert!(!signing_enabled(&repo.git2().unwrap())); + #[test] + fn hook_output_is_bounded_and_preserves_final_failure() { + let (repo, dir) = scratch_repo(); + stage(&dir, "a.txt", "a\n"); + hook(&dir.join(".git/hooks"), "pre-commit", "i=0; while [ $i -lt 3000 ]; do echo verbose-hook-output; echo verbose-stderr >&2; i=$((i+1)); done; echo final-rejection >&2; exit 1"); + let error = repo.commit("draft", None, false).unwrap_err().to_string(); + assert!(error.len() < 34 * 1024); + assert!(error.contains("output truncated")); + assert!(error.ends_with("final-rejection")); + let _ = std::fs::remove_dir_all(dir); + } + #[test] + #[ignore = "manual no-hook latency measurement"] + fn measure_no_hook_commit_path() { + let (repo, dir) = scratch_repo(); + let mut samples = Vec::new(); + for i in 0..25 { + stage(&dir, "a.txt", &format!("{i}\n")); + let start = std::time::Instant::now(); + repo.commit("measurement", None, false).unwrap(); + samples.push(start.elapsed().as_secs_f64() * 1000.0); + } + let mut previous = Vec::new(); + for i in 0..25 { + stage(&dir, "a.txt", &format!("old-{i}\n")); + let start = std::time::Instant::now(); + let g2 = repo.git2().unwrap(); + let sig = g2.signature().unwrap(); + let tree_oid = g2.index().unwrap().write_tree().unwrap(); + let tree = g2.find_tree(tree_oid).unwrap(); + let parent = g2.head().unwrap().peel_to_commit().unwrap(); + g2.commit(Some("HEAD"), &sig, &sig, "measurement", &tree, &[&parent]).unwrap(); + previous.push(start.elapsed().as_secs_f64() * 1000.0); + } + previous.sort_by(f64::total_cmp); + println!("previous git2 path: median {:.2}ms, p95 {:.2}ms (25 iterations)", previous[12], previous[23]); + samples.sort_by(f64::total_cmp); + println!("no-hook Git commit: median {:.2}ms, p95 {:.2}ms (25 iterations)", samples[12], samples[23]); let _ = std::fs::remove_dir_all(dir); } @@ -242,7 +259,7 @@ mod tests { git(&dir, &["commit", "-q", "-m", "base"]); stage(&dir, "a.txt", "a\n"); - repo.commit_via_git("subject\n\nbody line\n", false).unwrap(); + repo.commit_via_git("subject\n\nbody line\n", false, SigningMode::Inherit).unwrap(); assert_eq!(git(&dir, &["log", "-1", "--format=%B"]), "subject\n\nbody line"); assert_eq!(git(&dir, &["rev-list", "--count", "HEAD"]), "2"); @@ -250,7 +267,7 @@ mod tests { // author and only updates the committer — assert that parity here. git(&dir, &["config", "user.name", "Other"]); git(&dir, &["config", "user.email", "other@example.com"]); - repo.commit_via_git("amended subject\n", true).unwrap(); + repo.commit_via_git("amended subject\n", true, SigningMode::Inherit).unwrap(); assert_eq!(git(&dir, &["log", "-1", "--format=%B"]), "amended subject"); assert_eq!(git(&dir, &["rev-list", "--count", "HEAD"]), "2", "amend replaces, not adds"); assert_eq!( @@ -273,7 +290,7 @@ mod tests { stage(&dir, "a.txt", "a\n"); repo.commit("original", None, false).unwrap(); - // Same parity check for the git2 path: a different configured user + // A different configured user // amends, the original author survives, the committer updates. git(&dir, &["config", "user.name", "Other"]); git(&dir, &["config", "user.email", "other@example.com"]); diff --git a/crates/strand-core/src/diff.rs b/crates/strand-core/src/diff.rs index 36ef83e1..68b97579 100644 --- a/crates/strand-core/src/diff.rs +++ b/crates/strand-core/src/diff.rs @@ -42,6 +42,22 @@ pub struct DiffPath { impl Repo { pub fn diff_unstaged_paths(&self) -> Result> { + if self.sparse_enabled() { + let bytes = self.sparse_git(&["diff", "--name-status", "-z", "--find-renames", "--no-ext-diff", "--no-textconv", "--"], None)?; + let mut fields = bytes.split(|b| *b == 0).filter(|row| !row.is_empty()); + let mut paths = Vec::new(); + while let Some(status) = fields.next() { + let Some(path) = fields.next() else { break; }; + let path = String::from_utf8_lossy(path).into_owned(); + let (path, old_path) = if status.starts_with(b"R") || status.starts_with(b"C") { + let Some(new) = fields.next() else { break; }; + (String::from_utf8_lossy(new).into_owned(), Some(path)) + } else { (path, None) }; + paths.push(DiffPath { path, old_path }); + } + paths.extend(self.status()?.into_iter().filter(|s| s.kind == crate::status::StatusKind::Untracked).map(|s| DiffPath { path: s.path, old_path: None })); + return Ok(paths); + } let repo = self.git2()?; let mut diff = repo.diff_index_to_workdir(None, Some(&mut diff_options()))?; let mut find = git2::DiffFindOptions::new(); @@ -57,6 +73,7 @@ impl Repo { /// Working tree vs index — the "unstaged" diff shown above the /// commit-form in Local Changes. pub fn diff_unstaged(&self) -> Result> { + if self.sparse_enabled() { return self.sparse_workdir_diff(false, None, 3, None); } let repo = self.git2()?; let mut opts = diff_options(); let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; @@ -65,6 +82,7 @@ impl Repo { /// Index vs HEAD — what `git diff --cached` would show. pub fn diff_staged(&self) -> Result> { + if self.sparse_enabled() { return self.sparse_workdir_diff(true, None, 3, None); } let repo = self.git2()?; let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok()); let mut opts = diff_options(); @@ -76,6 +94,11 @@ impl Repo { /// and the file view's Compare tab. pub fn diff_between(&self, from: &str, to: &str) -> Result> { let repo = self.git2()?; + if self.is_partial_clone() { + let from = repo.revparse_single(from)?.peel_to_commit()?.id().to_string(); + let to = repo.revparse_single(to)?.peel_to_commit()?.id().to_string(); + return self.git_revision_diff(&[&from, &to, "--"], false); + } let from_tree = repo.revparse_single(from)?.peel_to_commit()?.tree()?; let to_tree = repo.revparse_single(to)?.peel_to_commit()?.tree()?; let mut opts = diff_options(); @@ -89,6 +112,10 @@ impl Repo { /// added. pub fn diff_commit(&self, oid: &str) -> Result> { let repo = self.git2()?; + if self.is_partial_clone() { + let oid = repo.revparse_single(oid)?.peel_to_commit()?.id().to_string(); + return self.git_revision_diff(&[&oid, "--"], true); + } let to_oid = repo.revparse_single(oid)?.id(); let to_commit = repo.find_commit(to_oid)?; let to_tree = to_commit.tree()?; @@ -110,6 +137,10 @@ impl Repo { /// it existed under a different name before a rename). pub fn diff_commit_file(&self, oid: &str, path: &str) -> Result> { let repo = self.git2()?; + if self.is_partial_clone() { + let oid = repo.revparse_single(oid)?.peel_to_commit()?.id().to_string(); + return self.git_revision_diff(&[&oid, "--", path], true); + } let to_commit = repo.revparse_single(oid)?.peel_to_commit()?; let to_tree = to_commit.tree()?; let from_tree = if to_commit.parent_count() == 0 { @@ -129,6 +160,7 @@ impl Repo { /// showing work the agent already staged or committed away from the /// baseline, so the reviewer sees the whole session in one diff. pub fn diff_since(&self, baseline: &str) -> Result> { + if self.sparse_enabled() || self.is_partial_clone() { return self.sparse_workdir_diff(false, Some(baseline), 3, None); } let repo = self.git2()?; let tree = repo.revparse_single(baseline)?.peel_to_commit()?.tree()?; let mut opts = diff_options(); @@ -140,6 +172,7 @@ impl Repo { /// carries the entire file, not just hunks. Powers the Review view, which /// shows an agent's edits in the context of the full file. pub fn diff_unstaged_full(&self) -> Result> { + if self.sparse_enabled() { return self.sparse_workdir_diff(false, None, WHOLE_FILE_CONTEXT, None); } let repo = self.git2()?; let mut opts = diff_options_with(WHOLE_FILE_CONTEXT); let diff = repo.diff_index_to_workdir(None, Some(&mut opts))?; @@ -148,6 +181,7 @@ impl Repo { /// `diff_since` with whole-file context — see `diff_unstaged_full`. pub fn diff_since_full(&self, baseline: &str) -> Result> { + if self.sparse_enabled() || self.is_partial_clone() { return self.sparse_workdir_diff(false, Some(baseline), WHOLE_FILE_CONTEXT, None); } let repo = self.git2()?; let tree = repo.revparse_single(baseline)?.peel_to_commit()?.tree()?; let mut opts = diff_options_with(WHOLE_FILE_CONTEXT); @@ -161,6 +195,9 @@ impl Repo { /// directly to the workdir (ignoring the index), so a half-staged file still /// shows its full on-disk delta; untracked files appear as additions. pub fn diff_workdir_file(&self, path: &str) -> Result> { + if self.sparse_enabled() { + return self.sparse_workdir_diff(false, Some("HEAD"), 3, Some(path)); + } let repo = self.git2()?; let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok()); let mut opts = diff_options(); @@ -168,6 +205,46 @@ impl Repo { let diff = repo.diff_tree_to_workdir(head_tree.as_ref(), Some(&mut opts))?; collect(diff) } + + fn sparse_workdir_diff(&self, staged: bool, baseline: Option<&str>, context: u32, path: Option<&str>) -> Result> { + let context_arg = format!("--unified={context}"); + let oid = baseline.map(|rev| self.git2()?.revparse_single(rev)?.peel_to_commit().map(|c| c.id().to_string()).map_err(crate::Error::from)).transpose()?; + let mut args = vec!["--literal-pathspecs", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--binary", "--no-relative", "--src-prefix=a/", "--dst-prefix=b/", "--find-renames", &context_arg]; + if staged { args.push("--cached"); } + if let Some(oid) = &oid { args.push(oid); } + args.push("--"); + if let Some(path) = path { args.push(path); } + let bytes = self.sparse_git(&args, None)?; + let mut files = if bytes.is_empty() { Vec::new() } else { collect_ready(git2::Diff::from_buffer(&bytes)?)? }; + if !staged { + let untracked: Vec<_> = self.status()?.into_iter().filter(|s| s.kind == crate::status::StatusKind::Untracked && path.is_none_or(|path| s.path == path)).collect(); + if !untracked.is_empty() { + // One path-limited libgit2 walk for all untracked contents; + // never spawn a Git process per untracked file. + let mut opts = diff_options_with(context); + opts.disable_pathspec_match(true); + for entry in untracked { opts.pathspec(entry.path); } + files.extend(collect(self.git2()?.diff_index_to_workdir(None, Some(&mut opts))?)?); + } + } + Ok(files) + } + + fn git_revision_diff(&self, revisions: &[&str], commit: bool) -> Result> { + let mut command = crate::git_command(); + command.current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0") + .args(crate::GIT_SAFE_CONFIG) + .arg("--literal-pathspecs"); + if commit { command.args(["show", "--format=", "--first-parent"]); } else { command.arg("diff"); } + let output = command.args(["--no-ext-diff", "--no-textconv", "--no-color", "--binary", "--no-relative", "--src-prefix=a/", "--dst-prefix=b/", "--find-renames"]) + .args(revisions).output()?; + if !output.status.success() { + return Err(crate::Error::Other(String::from_utf8_lossy(&output.stderr).trim().into())); + } + if output.stdout.is_empty() { return Ok(Vec::new()); } + collect_ready(git2::Diff::from_buffer(&output.stdout)?) + } } /// "Whole file" context: big enough that one hunk swallows any real file, @@ -199,6 +276,10 @@ fn collect(mut diff: git2::Diff<'_>) -> Result> { let mut find = git2::DiffFindOptions::new(); find.renames(true).copies(true); diff.find_similar(Some(&mut find))?; + collect_ready(diff) +} + +fn collect_ready(diff: git2::Diff<'_>) -> Result> { // Pre-populate one FileDiff per delta so the print callback can index // into us by delta_idx. diff --git a/crates/strand-core/src/file.rs b/crates/strand-core/src/file.rs index 294f275e..6d95ee1c 100644 --- a/crates/strand-core/src/file.rs +++ b/crates/strand-core/src/file.rs @@ -87,9 +87,7 @@ impl Repo { let entry = tree.get_path(Path::new(rel_path)).map_err(|_| { Error::Other(format!("{rel_path} does not exist at {spec}")) })?; - let blob = repo - .find_blob(entry.id()) - .map_err(|_| Error::Other(format!("{rel_path} is not a file at {spec}")))?; + let blob = self.find_blob(entry.id())?; Ok(build_content(rel_path, blob.content(), blob.is_binary())) } } @@ -161,9 +159,7 @@ impl Repo { .index()? .get_path(Path::new(rel_path), 0) .ok_or_else(|| Error::Other(format!("{rel_path} is not in the index")))?; - let blob = repo - .find_blob(entry.id) - .map_err(|_| Error::Other(format!("{rel_path} is not a file in the index")))?; + let blob = self.find_blob(entry.id)?; Ok(build_blob(blob.content())) } BlobSource::Rev(spec) => { @@ -172,9 +168,7 @@ impl Repo { let entry = tree.get_path(Path::new(rel_path)).map_err(|_| { Error::Other(format!("{rel_path} does not exist at {spec}")) })?; - let blob = repo - .find_blob(entry.id()) - .map_err(|_| Error::Other(format!("{rel_path} is not a file at {spec}")))?; + let blob = self.find_blob(entry.id())?; Ok(build_blob(blob.content())) } } diff --git a/crates/strand-core/src/git_output.rs b/crates/strand-core/src/git_output.rs new file mode 100644 index 00000000..5adb7b7c --- /dev/null +++ b/crates/strand-core/src/git_output.rs @@ -0,0 +1,59 @@ +//! Bounded pipe capture for user-triggered Git commands. Drain both pipes to +//! EOF even after the limit, retaining the start and final diagnostics. + +use std::io::Read; +use std::process::{Command, Output, Stdio}; + +const HALF_LIMIT: usize = 8 * 1024; + +fn drain(mut pipe: impl Read) -> std::io::Result> { + let mut head = Vec::new(); + let mut tail = Vec::new(); + let mut total = 0; + let mut buffer = [0; 8192]; + loop { + let n = pipe.read(&mut buffer)?; + if n == 0 { break; } + total += n; + let split = n.min(HALF_LIMIT - head.len()); + head.extend_from_slice(&buffer[..split]); + tail.extend_from_slice(&buffer[split..n]); + if tail.len() > HALF_LIMIT { + tail.drain(..tail.len() - HALF_LIMIT); + } + } + if total > HALF_LIMIT * 2 { + head.extend_from_slice(b"\n[output truncated; final diagnostics follow]\n"); + } + head.extend(tail); + Ok(head) +} + +pub(crate) fn capture(command: &mut Command) -> crate::Result { + let mut child = command.stdin(Stdio::null()) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let reader = std::thread::spawn(move || drain(stdout)); + let stderr = drain(stderr); + let status = child.wait()?; + let stdout = reader.join().map_err(|_| crate::Error::Other("Git output reader failed".into()))??; + Ok(Output { status, stdout, stderr: stderr? }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retains_start_and_end_without_unbounded_allocation() { + let mut bytes = b"start\n".to_vec(); + bytes.extend(vec![b'x'; 1024 * 1024]); + bytes.extend_from_slice(b"\nfinal error"); + let output = drain(bytes.as_slice()).unwrap(); + assert!(output.len() < 17 * 1024); + assert!(output.starts_with(b"start\n")); + assert!(output.ends_with(b"\nfinal error")); + assert!(String::from_utf8(output).unwrap().contains("output truncated")); + } +} diff --git a/crates/strand-core/src/gitconfig.rs b/crates/strand-core/src/gitconfig.rs index f13b02e0..dfa7caf8 100644 --- a/crates/strand-core/src/gitconfig.rs +++ b/crates/strand-core/src/gitconfig.rs @@ -1,13 +1,140 @@ -//! Global git configuration access — the user-level identity (`user.name` / -//! `user.email`) shown and edited in Settings → Git. Reads resolve the same -//! merged view git itself uses (system + global + XDG); writes always target -//! the **global** file, never a repo's `.git/config`. +//! Global defaults and effective repository identity. Repository reads use +//! system Git so conditional/worktree config and environment match commits. +//! Writes target only the explicitly selected global or direct local config. use std::path::PathBuf; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::error::Result; +use crate::{Error, Repo}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopedValue { + pub value: String, + pub scope: String, + pub origin: String, +} + +#[derive(Debug, Serialize)] +pub struct EffectiveIdentity { + pub identity: Option, + pub error: Option, + pub name_source: ScopedValue, + pub email_source: ScopedValue, +} + +#[derive(Debug, Serialize)] +pub struct RepositoryIdentity { + pub author: EffectiveIdentity, + pub committer: EffectiveIdentity, + pub local: GlobalIdentity, +} + +pub(crate) type ConfigValues = std::collections::BTreeMap; + +pub(crate) fn config_values(repo: &Repo, scope: Option<&str>, pattern: &str) -> Result { + let mut args = vec!["config", "--null", "--show-scope", "--show-origin"]; + if let Some(scope) = scope { args.extend([scope, "--no-includes"]); } + else { args.push("--includes"); } + args.extend(["--get-regexp", pattern]); + let out = config_git(repo, &args)?; + if !out.status.success() && out.status.code() != Some(1) { + return Err(config_error(&out)); + } + let text = String::from_utf8_lossy(&out.stdout); + if text.contains("[output truncated;") { + return Err(Error::Other("Git identity/config output exceeded the display limit".into())); + } + let mut fields = text.split_terminator('\0'); + let mut values = ConfigValues::new(); + while let Some(scope) = fields.next() { + let origin = fields.next().ok_or_else(|| Error::Other("Invalid Git config origin".into()))?; + let entry = fields.next().ok_or_else(|| Error::Other("Invalid Git config value".into()))?; + let (key, value) = entry.split_once('\n').map_or((entry, None), |(key, value)| (key, Some(value))); + // Git distinguishes a valueless boolean (true) from an explicitly + // empty value (false). Keep both editable as actual signing states. + let value = if matches!(key, "commit.gpgsign" | "tag.gpgsign" | "tag.forcesignannotated") { + match value { None => "true", Some("") => "false", Some(value) => value } + } else { value.unwrap_or_default() }; + values.insert(key.to_owned(), ScopedValue { + value: value.to_owned(), scope: scope.to_owned(), origin: origin.to_owned(), + }); + } + Ok(values) +} + +fn config_git(repo: &Repo, args: &[&str]) -> Result { + crate::git_output::capture(crate::git_command().current_dir(&repo.path) + .env("GIT_TERMINAL_PROMPT", "0").args(crate::GIT_SAFE_CONFIG).args(args)) +} + +fn config_error(output: &std::process::Output) -> Error { + Error::Other(format!("Git config: {}", String::from_utf8_lossy(&output.stderr).trim())) +} + +fn identity_source(values: &ConfigValues, role: &str, field: &str) -> ScopedValue { + let env_key = format!("GIT_{}_{}", role.to_uppercase(), field.to_uppercase()); + if let Ok(value) = std::env::var(&env_key) { + return ScopedValue { value, scope: "environment".into(), origin: env_key }; + } + values.get(&format!("{role}.{field}")).or_else(|| values.get(&format!("user.{field}"))) + .cloned().unwrap_or_else(|| ScopedValue { + value: String::new(), scope: "fallback".into(), origin: "Git environment/system fallback".into(), + }) +} + +impl Repo { + /// Read using the same Git resolver as commit, including conditional + /// includes, worktree config, author/committer overrides and environment. + /// Only queried on the settings surface, never on status/log refresh. + pub fn repository_identity(&self) -> Result { + let values = config_values(self, None, "^(user|author|committer)\\.(name|email)$")?; + let local = config_values(self, Some("--local"), "^user\\.(name|email)$")?; + let identity = |role: &str| -> Result { + let variable = format!("GIT_{}_IDENT", role.to_uppercase()); + let out = config_git(self, &["var", &variable])?; + let text = String::from_utf8_lossy(&out.stdout); + Ok(EffectiveIdentity { + identity: out.status.success().then(|| text.rsplit_once('>').map(|(id, _)| format!("{id}>")) + .unwrap_or_else(|| text.trim().to_owned())), + error: (!out.status.success()).then(|| String::from_utf8_lossy(&out.stderr).trim().to_owned()), + name_source: identity_source(&values, role, "name"), + email_source: identity_source(&values, role, "email"), + }) + }; + Ok(RepositoryIdentity { + author: identity("author")?, committer: identity("committer")?, + local: GlobalIdentity { + name: local.get("user.name").map(|v| v.value.clone()), + email: local.get("user.email").map(|v| v.value.clone()), + }, + }) + } + + /// Write only the selected key in the common repository config. Git's + /// --local writes never follow includes back into global/conditional files. + pub fn set_repository_identity(&self, field: &str, value: Option<&str>) -> Result<()> { + let key = match field { + "name" => "user.name", "email" => "user.email", + _ => return Err(Error::Other("Unknown identity field".into())), + }; + self.set_scoped_config("--local", key, value) + } + + pub(crate) fn set_scoped_config(&self, scope: &str, key: &str, value: Option<&str>) -> Result<()> { + if value.is_some_and(|v| v.trim().is_empty() || v.len() > 4096 || v.contains(['\0', '\r', '\n'])) { + return Err(Error::Other("Use a non-empty, single-line config value (up to 4096 bytes), or remove the override".into())); + } + let args = match value { + Some(value) => vec!["config", scope, "--replace-all", key, value], + None => vec!["config", scope, "--unset-all", key], + }; + let out = config_git(self, &args)?; + if out.status.success() || (value.is_none() && out.status.code() == Some(5)) { Ok(()) } + else { Err(config_error(&out)) } + } +} #[derive(Debug, Serialize)] pub struct GlobalIdentity { @@ -56,6 +183,68 @@ fn default_global_path() -> PathBuf { mod tests { use super::*; + #[test] + fn repository_overrides_preserve_conditional_identity_and_other_repositories() { + let dir = std::env::temp_dir().join(format!("strand-identity-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let first = dir.join("one"); + let second = dir.join("two"); + for path in [&first, &second] { + let g2 = git2::Repository::init(path).unwrap(); + let mut config = g2.config().unwrap(); + config.set_str("user.name", "Base").unwrap(); + config.set_str("user.email", "base@example.com").unwrap(); + } + let repo = Repo::discover(&first).unwrap(); + let other = Repo::discover(&second).unwrap(); + let included = dir.join("conditional.gitconfig"); + let content = "[user]\nname = Conditional\nemail = conditional@example.com\n"; + std::fs::write(&included, content).unwrap(); + let mut config = repo.git2().unwrap().config().unwrap(); + let condition = format!("includeIf.gitdir:{}/.git.path", first.to_string_lossy().replace('\\', "/")); + config.set_str(&condition, &included.to_string_lossy().replace('\\', "/")).unwrap(); + assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Conditional ")); + repo.set_repository_identity("name", Some("Local")).unwrap(); + repo.set_repository_identity("email", Some("local@example.com")).unwrap(); + // The direct local keys occur before the include, so Git correctly + // keeps the later conditional identity effective. UI shows both. + let state = repo.repository_identity().unwrap(); + assert_eq!(state.local.name.as_deref(), Some("Local")); + assert!(state.author.name_source.origin.contains("conditional.gitconfig")); + repo.set_repository_identity("name", None).unwrap(); + repo.set_repository_identity("email", None).unwrap(); + assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Conditional ")); + assert_eq!(std::fs::read_to_string(&included).unwrap(), content); + assert_eq!(other.repository_identity().unwrap().author.identity.as_deref(), Some("Base ")); + assert!(repo.set_repository_identity("signingkey", Some("bad")).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn local_identity_is_shared_by_linked_worktrees_and_worktree_identity_stays_effective() { + let dir = std::env::temp_dir().join(format!("strand-linked-identity-{}", std::process::id())); + let main = dir.join("main"); + let linked = dir.join("linked"); + std::fs::create_dir_all(&main).unwrap(); + let repo = Repo::discover({ git2::Repository::init(&main).unwrap(); &main }).unwrap(); + repo.set_repository_identity("name", Some("Shared")).unwrap(); + repo.set_repository_identity("email", Some("shared@example.com")).unwrap(); + let out = config_git(&repo, &["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "base"]).unwrap(); + assert!(out.status.success()); + assert!(config_git(&repo, &["worktree", "add", "-b", "linked", linked.to_str().unwrap()]).unwrap().status.success()); + let worktree = Repo::discover(&linked).unwrap(); + worktree.set_repository_identity("name", Some("Both")).unwrap(); + assert_eq!(repo.repository_identity().unwrap().author.identity.as_deref(), Some("Both ")); + assert!(config_git(&repo, &["config", "extensions.worktreeConfig", "true"]).unwrap().status.success()); + assert!(config_git(&worktree, &["config", "--worktree", "user.name", "Worktree"]).unwrap().status.success()); + worktree.set_repository_identity("name", None).unwrap(); + let identity = worktree.repository_identity().unwrap(); + assert_eq!(identity.author.identity.as_deref(), Some("Worktree ")); + assert_eq!(identity.author.name_source.scope, "worktree"); + assert_eq!(identity.local.name, None); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn identity_round_trips_through_a_config_file() { let dir = std::env::temp_dir().join(format!( diff --git a/crates/strand-core/src/gitflow.rs b/crates/strand-core/src/gitflow.rs new file mode 100644 index 00000000..3573d994 --- /dev/null +++ b/crates/strand-core/src/gitflow.rs @@ -0,0 +1,809 @@ +//! Opt-in Git-flow AVH orchestration. No work is added to ordinary snapshots. +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + hash::{Hash, Hasher}, + io::{Read, Write}, + path::Path, + process::Stdio, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlowConfig { + pub production: String, + pub develop: String, + pub feature: String, + pub release: String, + pub hotfix: String, + pub version_tag: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowState { + pub enabled: bool, + pub config: FlowConfig, + pub options: BTreeMap, + pub branches: BTreeMap, + pub current: String, + pub head: String, + pub operation: Option, + pub clean: bool, + pub conflicts: bool, + pub token: String, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FlowKind { + Feature, + Release, + Hotfix, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FlowAction { + Start, + Finish, + ContinueMerge, + AbortMerge, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowPlan { + pub kind: FlowKind, + pub action: FlowAction, + pub name: String, + pub token: String, + pub args: Vec, + pub steps: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowTool { + pub available: bool, + pub version: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowOutcome { + pub success: bool, + pub output: String, + pub state: FlowState, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interchange::InterchangeScratch; + fn git(path: &Path, args: &[&str]) -> String { + let (ok, output) = run( + path, + &args.iter().map(|s| s.to_string()).collect::>(), + |_| {}, + ) + .unwrap(); + assert!(ok, "{args:?}: {output}"); + output.trim().into() + } + fn fixture() -> (InterchangeScratch, Repo, FlowConfig) { + let scratch = InterchangeScratch::new().unwrap(); + let raw = git2::Repository::init(&scratch.0).unwrap(); + let mut cfg = raw.config().unwrap(); + for (key, value) in [ + ("user.name", "Flow Tester"), + ("user.email", "flow@example.test"), + ("core.hooksPath", "/dev/null"), + ("core.autocrlf", "false"), + ] { + cfg.set_str(key, value).unwrap(); + } + cfg.set_bool("commit.gpgsign", false).unwrap(); + cfg.set_bool("tag.gpgsign", false).unwrap(); + fs::write(scratch.0.join("file"), "base\n").unwrap(); + git(&scratch.0, &["add", "."]); + git(&scratch.0, &["commit", "-m", "base"]); + git(&scratch.0, &["branch", "-M", "main"]); + git(&scratch.0, &["branch", "develop"]); + let repo = Repo::discover(&scratch.0).unwrap(); + let cfg = FlowConfig { + production: "main".into(), + develop: "develop".into(), + feature: "feature/".into(), + release: "release/".into(), + hotfix: "hotfix/".into(), + version_tag: "v".into(), + }; + (scratch, repo, cfg) + } + fn enable(repo: &Repo, cfg: FlowConfig) { + repo.configure_gitflow(cfg, true, &repo.gitflow_state().unwrap().token) + .unwrap(); + } + fn execute(repo: &Repo, kind: FlowKind, action: FlowAction, name: &str) -> FlowOutcome { + repo.run_gitflow(repo.plan_gitflow(kind, action, name).unwrap(), |_| {}) + .unwrap() + } + #[test] + fn config_is_opt_in_atomic_and_preserves_external_settings() { + let (_s, repo, cfg) = fixture(); + assert!(!repo.gitflow_state().unwrap().enabled); + assert!(repo + .plan_gitflow(FlowKind::Feature, FlowAction::Start, "one") + .is_err()); + let stale = repo.gitflow_state().unwrap(); + git(&repo.path, &["config", "gitflow.origin", "upstream"]); + assert!(repo + .configure_gitflow(cfg.clone(), true, &stale.token) + .is_err()); + enable(&repo, cfg.clone()); + assert_eq!(git(&repo.path, &["config", "gitflow.origin"]), "upstream"); + assert_eq!(repo.gitflow_state().unwrap().config, cfg); + let token = repo.gitflow_state().unwrap().token; + let lock = repo.git_dir().join("config.lock"); + fs::write(&lock, "external lock").unwrap(); + assert!(repo.configure_gitflow(cfg.clone(), false, &token).is_err()); + assert_eq!(fs::read_to_string(&lock).unwrap(), "external lock"); + fs::remove_file(lock).unwrap(); + repo.configure_gitflow(cfg, false, &token).unwrap(); + assert!(!repo.gitflow_state().unwrap().enabled); + assert_eq!( + git(&repo.path, &["config", "gitflow.branch.master"]), + "main" + ); + } + #[test] + #[ignore = "requires installed Git-flow AVH; run with --include-ignored"] + fn starts_and_finishes_all_three_kinds_without_publication_or_branch_deletion() { + assert!(detect().unwrap().available); + let (_s, repo, cfg) = fixture(); + enable(&repo, cfg); + // Even existing push/fetch preferences must not make the reviewed local + // workflow publish, fetch, or delete branches. + for kind in ["feature", "release", "hotfix"] { + for flag in ["push", "fetch"] { + git( + &repo.path, + &["config", &format!("gitflow.{kind}.finish.{flag}"), "true"], + ); + } + } + for flag in ["pushproduction", "pushdevelop", "pushtag"] { + git( + &repo.path, + &["config", &format!("gitflow.release.finish.{flag}"), "true"], + ); + } + for (kind, label) in [ + (FlowKind::Feature, "feature"), + (FlowKind::Release, "release"), + (FlowKind::Hotfix, "hotfix"), + ] { + let started = execute(&repo, kind, FlowAction::Start, label); + assert!(started.success, "{}", started.output); + fs::write(repo.path.join(label), "work\n").unwrap(); + git(&repo.path, &["add", label]); + git(&repo.path, &["commit", "-m", label]); + let tip = git(&repo.path, &["rev-parse", "HEAD"]); + let finished = execute(&repo, kind, FlowAction::Finish, label); + assert!(finished.success, "{}", finished.output); + assert!(finished.state.clean && finished.state.operation.is_none()); + assert_eq!( + git(&repo.path, &["rev-parse", &format!("{label}/{label}")]), + tip + ); + git( + &repo.path, + &["merge-base", "--is-ancestor", &tip, "develop"], + ); + if kind != FlowKind::Feature { + git(&repo.path, &["merge-base", "--is-ancestor", &tip, "main"]); + assert_eq!( + git(&repo.path, &["cat-file", "-t", &format!("v{label}")]), + "tag" + ); + } + } + } + #[test] + #[ignore = "requires installed Git-flow AVH; run with --include-ignored"] + fn external_start_stale_review_and_conflict_continue_or_abort() { + let (_s, repo, cfg) = fixture(); + enable(&repo, cfg); + git(&repo.path, &["flow", "feature", "start", "external"]); + fs::write(repo.path.join("file"), "feature\n").unwrap(); + git(&repo.path, &["commit", "-am", "feature edit"]); + let stale = repo + .plan_gitflow(FlowKind::Feature, FlowAction::Finish, "external") + .unwrap(); + git(&repo.path, &["checkout", "develop"]); + fs::write(repo.path.join("file"), "develop\n").unwrap(); + git(&repo.path, &["commit", "-am", "develop edit"]); + assert!(repo.run_gitflow(stale, |_| {}).is_err()); + let paused = execute(&repo, FlowKind::Feature, FlowAction::Finish, "external"); + assert!(!paused.success && paused.state.conflicts); + assert!(repo + .plan_gitflow(FlowKind::Feature, FlowAction::ContinueMerge, "external") + .is_err()); + let aborted = execute(&repo, FlowKind::Feature, FlowAction::AbortMerge, "external"); + assert!(aborted.success && aborted.state.clean); + let paused = execute(&repo, FlowKind::Feature, FlowAction::Finish, "external"); + assert!(!paused.success); + fs::write(repo.path.join("file"), "resolved\n").unwrap(); + git(&repo.path, &["add", "file"]); + assert!( + execute( + &repo, + FlowKind::Feature, + FlowAction::ContinueMerge, + "external" + ) + .success + ); + let done = execute(&repo, FlowKind::Feature, FlowAction::Finish, "external"); + assert!(done.success, "{}", done.output); + assert_eq!( + fs::read_to_string(repo.path.join("file")).unwrap(), + "resolved\n" + ); + assert!(repo + .plan_gitflow( + FlowKind::Feature, + FlowAction::Start, + "bad'$(touch injected)" + ) + .is_err()); + assert!(repo + .plan_gitflow(FlowKind::Feature, FlowAction::Finish, "exter") + .is_err()); + } + + #[test] + #[ignore = "requires installed Git-flow AVH; run with --include-ignored"] + fn release_recovery_preserves_completed_production_merge_and_tag() { + let (_s, repo, cfg) = fixture(); + enable(&repo, cfg); + assert!(execute(&repo, FlowKind::Release, FlowAction::Start, "partial").success); + fs::write(repo.path.join("file"), "release\n").unwrap(); + git(&repo.path, &["commit", "-am", "release edit"]); + git(&repo.path, &["checkout", "develop"]); + fs::write(repo.path.join("file"), "develop\n").unwrap(); + git(&repo.path, &["commit", "-am", "develop edit"]); + let paused = execute(&repo, FlowKind::Release, FlowAction::Finish, "partial"); + assert!( + !paused.success && paused.state.conflicts, + "{}", + paused.output + ); + let tag = git(&repo.path, &["rev-parse", "vpartial"]); + let main = git(&repo.path, &["rev-parse", "main"]); + let abort = repo + .plan_gitflow(FlowKind::Release, FlowAction::AbortMerge, "partial") + .unwrap(); + fs::write( + repo.path.join("file"), + "new external resolution with different length\n", + ) + .unwrap(); + assert!(repo.run_gitflow(abort, |_| {}).is_err()); + assert!(execute(&repo, FlowKind::Release, FlowAction::AbortMerge, "partial").success); + assert_eq!(git(&repo.path, &["rev-parse", "main"]), main); + assert_eq!(git(&repo.path, &["rev-parse", "vpartial"]), tag); + assert!(!execute(&repo, FlowKind::Release, FlowAction::Finish, "partial").success); + fs::write(repo.path.join("file"), "resolved\n").unwrap(); + git(&repo.path, &["add", "file"]); + assert!( + execute( + &repo, + FlowKind::Release, + FlowAction::ContinueMerge, + "partial" + ) + .success + ); + let done = execute(&repo, FlowKind::Release, FlowAction::Finish, "partial"); + assert!(done.success, "{}", done.output); + assert_eq!(git(&repo.path, &["rev-parse", "vpartial"]), tag); + } +} + +// Drain both pipes with bounded queues and transcripts. Git-flow writes normal +// progress to stdout as well as stderr. Updates are coalesced before IPC. +fn run(cwd: &Path, args: &[String], mut progress: impl FnMut(String)) -> Result<(bool, String)> { + let mut child = crate::git_command() + .current_dir(cwd) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .env("GIT_EDITOR", "true") + .env("GIT_MERGE_AUTOEDIT", "no") + .env("GIT_TERMINAL_PROMPT", "0") + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let (tx, rx) = std::sync::mpsc::sync_channel::>(16); + let pipes: Vec> = vec![ + Box::new(child.stdout.take().unwrap()), + Box::new(child.stderr.take().unwrap()), + ]; + for mut pipe in pipes { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut buf = [0; 4096]; + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + } + } + }); + } + drop(tx); + let mut output = String::new(); + let mut last = std::time::Instant::now(); + while let Ok(chunk) = rx.recv() { + output.push_str(&String::from_utf8_lossy(&chunk)); + if output.len() > 65536 { + let mut cut = output.len() - 65536; + while !output.is_char_boundary(cut) { + cut += 1; + } + output.drain(..cut); + } + if last.elapsed().as_millis() >= 100 { + progress(output.clone()); + last = std::time::Instant::now(); + } + } + let success = child.wait()?.success(); + progress(output.clone()); + Ok((success, output)) +} + +pub fn detect() -> Result { + // A repository-local alias must never be used for discovery. Actual Git-flow + // executables take precedence over aliases once the extension is installed. + let scratch = crate::interchange::InterchangeScratch::new()?; + let (success, version) = run(&scratch.0, &["flow".into(), "version".into()], |_| {})?; + Ok(FlowTool { + available: success && version.contains("AVH Edition"), + version: version.trim().into(), + }) +} +fn safe_name(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['-', '.']) + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-._/".contains(&c)) + && git2::Reference::is_valid_name(&format!("refs/heads/{value}")) +} +fn validate_config(config: &FlowConfig, state: &FlowState) -> Result<()> { + if config.production == config.develop + || [&config.production, &config.develop] + .iter() + .any(|n| !safe_name(n) || !state.branches.contains_key(*n)) + { + return Err(Error::Other( + "select distinct, existing production and develop branches".into(), + )); + } + let prefixes = [&config.feature, &config.release, &config.hotfix]; + for (i, prefix) in prefixes.iter().enumerate() { + if !prefix.ends_with('/') + || !safe_name(&format!("{prefix}example")) + || prefixes + .iter() + .enumerate() + .any(|(j, p)| i != j && prefix.starts_with(p.as_str())) + { + return Err(Error::Other( + "use distinct, non-overlapping branch prefixes ending in /".into(), + )); + } + if [&config.production, &config.develop] + .iter() + .any(|b| b.starts_with(prefix.as_str())) + { + return Err(Error::Other( + "base branches cannot use a workflow prefix".into(), + )); + } + } + if !safe_name(&format!("{}example", config.version_tag)) { + return Err(Error::Other("invalid version tag prefix".into())); + } + Ok(()) +} + +impl Repo { + pub fn gitflow_state(&self) -> Result { + let repo = self.git2_owned()?; + let cfg = repo.config()?.snapshot()?; + let get = + |key: &str, fallback: &str| cfg.get_string(key).unwrap_or_else(|_| fallback.into()); + let config = FlowConfig { + production: get("gitflow.branch.master", ""), + develop: get("gitflow.branch.develop", ""), + feature: get("gitflow.prefix.feature", "feature/"), + release: get("gitflow.prefix.release", "release/"), + hotfix: get("gitflow.prefix.hotfix", "hotfix/"), + version_tag: get("gitflow.prefix.versiontag", ""), + }; + let mut options = BTreeMap::new(); + let mut entries = cfg.entries(Some("^gitflow\\."))?; + while let Some(entry) = entries.next() { + let entry = entry?; + if options.len() >= 1000 || entry.value_bytes().len() > 16384 { + return Err(Error::Other( + "Git-flow configuration is too large to inspect".into(), + )); + } + options.insert( + entry + .name() + .ok_or_else(|| Error::Other("Git-flow key is not UTF-8".into()))? + .into(), + entry + .value() + .ok_or_else(|| Error::Other("Git-flow value is not UTF-8".into()))? + .into(), + ); + } + let mut branches = BTreeMap::new(); + let mut refs = BTreeMap::new(); + for r in repo.references()? { + let r = r?; + if refs.len() >= 20000 { + return Err(Error::Other("too many refs for Git-flow review".into())); + } + if let (Some(name), Some(oid)) = (r.name(), r.target()) { + refs.insert(name.to_owned(), oid.to_string()); + if let Some(name) = name.strip_prefix("refs/heads/") { + branches.insert(name.into(), oid.to_string()); + } + } + } + let head_ref = repo.head()?; + let head = head_ref.peel_to_commit()?.id().to_string(); + let current = if head_ref.is_branch() { + head_ref.shorthand().unwrap_or("").to_owned() + } else { + String::new() + }; + let statuses = repo.statuses(Some( + git2::StatusOptions::new() + .include_untracked(true) + .recurse_untracked_dirs(true), + ))?; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + for status in &statuses { + status.path_bytes().hash(&mut hash); + status.status().bits().hash(&mut hash); + if let Some(path) = status.path() { + if let Ok(meta) = fs::symlink_metadata(self.path.join(path)) { + meta.len().hash(&mut hash); + meta.modified().ok().hash(&mut hash); + } + } + } + for marker in ["HEAD", "MERGE_HEAD", "MERGE_MSG", "ORIG_HEAD", "index"] { + let path = self.git_dir().join(marker); + if let Ok(meta) = fs::metadata(&path) { + meta.len().hash(&mut hash); + meta.modified().ok().hash(&mut hash); + } + } + let enabled = cfg.get_bool("strand.gitflow.enabled").unwrap_or(false); + enabled.hash(&mut hash); + refs.hash(&mut hash); + options.hash(&mut hash); + current.hash(&mut hash); + head.hash(&mut hash); + let operation = self.operation_in_progress(); + operation.hash(&mut hash); + Ok(FlowState { + enabled, + config, + options, + branches, + current, + head, + operation, + clean: statuses.is_empty(), + conflicts: repo.index()?.has_conflicts(), + token: format!("{:016x}", hash.finish()), + }) + } + + pub fn configure_gitflow( + &self, + config: FlowConfig, + enabled: bool, + token: &str, + ) -> Result { + let before = self.gitflow_state()?; + if before.token != token { + return Err(Error::Other( + "repository/config changed externally; refresh before saving".into(), + )); + } + if before.operation.is_some() { + return Err(Error::Other( + "finish the active Git operation before changing Git-flow settings".into(), + )); + } + if enabled { + validate_config(&config, &before)?; + } + let repo = self.git2_owned()?; + if repo + .config()? + .get_bool("extensions.worktreeConfig") + .unwrap_or(false) + { + return Err(Error::Other( + "manage per-worktree Git-flow configuration externally".into(), + )); + } + let config_path = self.gix.common_dir().join("config"); + if fs::symlink_metadata(&config_path)?.file_type().is_symlink() { + return Err(Error::Other( + "manage symlinked Git config externally".into(), + )); + } + let lock_path = config_path.with_extension("lock"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path)?; + struct Lock(std::path::PathBuf); + impl Drop for Lock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let _lock = Lock(lock_path.clone()); + if self.gitflow_state()?.token != token { + return Err(Error::Other( + "repository/config changed externally; refresh before saving".into(), + )); + } + if fs::metadata(&config_path)?.len() > 4 * 1024 * 1024 { + return Err(Error::Other( + "Git config exceeds the 4 MiB editing limit".into(), + )); + } + let bytes = fs::read(&config_path)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::set_permissions(&lock_path, fs::metadata(&config_path)?.permissions())?; + let mut local = git2::Config::open(&lock_path)?; + local.set_bool("strand.gitflow.enabled", enabled)?; + if enabled { + for (key, value) in [ + ("gitflow.branch.master", &config.production), + ("gitflow.branch.develop", &config.develop), + ("gitflow.prefix.feature", &config.feature), + ("gitflow.prefix.release", &config.release), + ("gitflow.prefix.hotfix", &config.hotfix), + ("gitflow.prefix.versiontag", &config.version_tag), + ] { + local.set_str(key, value)?; + } + for (key, value) in [ + ("gitflow.prefix.support", "support/"), + ("gitflow.prefix.bugfix", "bugfix/"), + ] { + if repo.config()?.get_string(key).is_err() { + local.set_str(key, value)?; + } + } + } + drop(local); + fs::rename(&lock_path, &config_path)?; + self.gitflow_state() + } + + pub fn plan_gitflow(&self, kind: FlowKind, action: FlowAction, name: &str) -> Result { + let state = self.gitflow_state()?; + if !state.enabled { + return Err(Error::Other( + "enable Git-flow for this repository first".into(), + )); + } + let mut steps = vec![format!( + "Current checkout: {} ({})", + state.current, state.head + )]; + let mut args = vec![]; + match action { + FlowAction::ContinueMerge | FlowAction::AbortMerge => { + if state.operation.as_deref() != Some("merge") { + return Err(Error::Other( + "no merge is active; refresh and review the next workflow step".into(), + )); + } + if action == FlowAction::ContinueMerge && state.conflicts { + return Err(Error::Other( + "resolve and stage every conflict before continuing the merge".into(), + )); + } + args.extend([ + "merge".into(), + if action == FlowAction::ContinueMerge { + "--continue" + } else { + "--abort" + } + .into(), + ]); + steps.push(if action == FlowAction::ContinueMerge { "Commit the staged merge resolution, then review Finish again to complete any remaining Git-flow stages." } else { "Abort only the current merge. Earlier completed merges/tags remain; this is not a rollback of the entire workflow." }.into()); + } + FlowAction::Start | FlowAction::Finish => { + validate_config(&state.config, &state)?; + if !state.clean || state.operation.is_some() || state.current.is_empty() { + return Err(Error::Other( + "start/finish requires a clean branch checkout and no active Git operation" + .into(), + )); + } + if !safe_name(name) { + return Err(Error::Other( + "use an exact workflow name containing letters, digits, /, -, _ or ." + .into(), + )); + } + if self.git_dir().join("gitflow_config").exists() { + return Err(Error::Other( + "migrate legacy gitflow_config with Git-flow before using this dialog" + .into(), + )); + } + let (kind_name, prefix, base) = match kind { + FlowKind::Feature => ("feature", &state.config.feature, &state.config.develop), + FlowKind::Release => ("release", &state.config.release, &state.config.develop), + FlowKind::Hotfix => ("hotfix", &state.config.hotfix, &state.config.production), + }; + let branch = format!("{prefix}{name}"); + if state + .options + .get(&format!("gitflow.branch.{branch}.base")) + .is_some_and(|b| b != base) + { + return Err(Error::Other("this workflow has a custom base; finish it with the external Git-flow tool".into())); + } + // AVH uses eval for tag message/key arguments; never let arbitrary + // message text cross that shell boundary. Generated copy is reviewed. + if state.options.iter().any(|(k, v)| { + k.ends_with(".signingkey") + && (v.starts_with('-') || v.contains(['\'', '\r', '\n'])) + }) { + return Err(Error::Other( + "unsupported Git-flow signing key; configure it externally".into(), + )); + } + args.extend([ + "flow".into(), + kind_name.into(), + if action == FlowAction::Start { + "start" + } else { + "finish" + } + .into(), + "--nofetch".into(), + ]); + if action == FlowAction::Start { + if state.branches.contains_key(&branch) { + return Err(Error::Other( + "workflow branch already exists; select Finish or a new name".into(), + )); + } + steps.push(format!( + "Create and check out {branch} from {base} ({})", + state.branches[base] + )); + args.extend([name.into(), base.clone()]); + } else { + let tip = state.branches.get(&branch).ok_or_else(|| { + Error::Other("exact workflow branch does not exist".into()) + })?; + args.extend(["--nopush".into(), "--keep".into(), "--nosquash".into()]); + if kind == FlowKind::Feature { + args.push("--norebase".into()); + } else { + args.extend([ + "--nonotag".into(), + "--nonobackmerge".into(), + format!("--message=Finish {kind_name} {name}"), + "--messagefile=".into(), + format!("--tagname={name}"), + ]); + if kind == FlowKind::Release { + args.extend([ + "--nopushproduction".into(), + "--nopushdevelop".into(), + "--nopushtag".into(), + "--nonodevelopmerge".into(), + "--noff-master".into(), + ]); + } + } + args.push(name.into()); + let repo = self.git2_owned()?; + let destinations = if kind == FlowKind::Feature { + vec![&state.config.develop] + } else { + vec![&state.config.production] + }; + for dest in destinations { + let dest_tip = &state.branches[dest]; + let source = git2::Oid::from_str(tip)?; + let destination = git2::Oid::from_str(dest_tip)?; + let merged = source == destination + || repo.graph_descendant_of(destination, source)?; + steps.push(format!( + "{branch} ({tip}) → {dest} ({dest_tip}){}", + if merged { + " — source already merged; AVH skips completed stages" + } else { + "" + } + )); + } + if kind != FlowKind::Feature { + let tag = format!("{}{name}", state.config.version_tag); + let existing = repo + .find_reference(&format!("refs/tags/{tag}")) + .ok() + .and_then(|r| r.target()) + .map(|o| o.to_string()); + steps.push(format!("Tag {tag}: {}. Annotation: Finish {kind_name} {name}. Configured signing is honored.", existing.map(|oid| format!("already exists at {oid}; Git-flow validates it before resuming")).unwrap_or_else(|| "create on production merge".into()))); + steps.push(format!("Back-merge the resulting production tag {tag} into {} (currently {}). Already completed merges are skipped.", state.config.develop, state.branches[&state.config.develop])); + } + steps.push(format!("Retain {branch} locally and remotely. No fetch or push. Git and Git-flow hooks still run.")); + } + } + } + Ok(FlowPlan { + kind, + action, + name: name.into(), + token: state.token, + args, + steps, + }) + } + + pub fn run_gitflow(&self, plan: FlowPlan, progress: impl FnMut(String)) -> Result { + let current = self.plan_gitflow(plan.kind, plan.action, &plan.name)?; + if current.token != plan.token || current.args != plan.args || current.steps != plan.steps { + return Err(Error::Other( + "Git refs, checkout or configuration changed; review the operation again".into(), + )); + } + if matches!(plan.action, FlowAction::Start | FlowAction::Finish) { + let tool = detect()?; + if !tool.available { + return Err(Error::Other(format!( + "Git-flow AVH is required: {}", + tool.version + ))); + } + if self.gitflow_state()?.token != plan.token { + return Err(Error::Other( + "repository changed during tool detection; review again".into(), + )); + } + } + let (success, output) = run(&self.path, ¤t.args, progress)?; + Ok(FlowOutcome { + success, + output, + state: self.gitflow_state()?, + }) + } +} diff --git a/crates/strand-core/src/history.rs b/crates/strand-core/src/history.rs index 7b3a02ed..f4c90dc3 100644 --- a/crates/strand-core/src/history.rs +++ b/crates/strand-core/src/history.rs @@ -370,6 +370,7 @@ impl Repo { .operation_in_progress() .ok_or_else(|| Error::Other("no operation in progress to continue".into()))?; let cmd = match op.as_str() { + "mailbox" => "am", "rebase" => "rebase", "cherry-pick" => "cherry-pick", "revert" => "revert", @@ -397,7 +398,7 @@ impl Repo { /// suppression). Same pause-aware mapping. fn run_sequencer_env(&self, args: &[&str], envs: &[(&str, &str)]) -> Result { match run_git_env(&self.path, args, envs) { - Ok(_) => Ok(self.operation_in_progress().is_some()), + Ok(_) => Ok(self.operation_in_progress().is_some_and(|op| op != "bisect")), Err(e) => { // A conflict is the expected paused outcome. Git can also // leave CHERRY_PICK_HEAD/REVERT_HEAD behind after a *real* @@ -415,6 +416,7 @@ impl Repo { /// Whether the index currently holds unmerged (conflicted) entries. fn has_conflicts(&self) -> Result { + if self.sparse_enabled() { return Ok(!self.sparse_git(&["ls-files", "--unmerged", "-z"], None)?.is_empty()); } Ok(self.git2()?.index()?.has_conflicts()) } @@ -428,6 +430,7 @@ impl Repo { .operation_in_progress() .ok_or_else(|| Error::Other("no operation in progress to abort".into()))?; let cmd = match op.as_str() { + "mailbox" => "am", "rebase" => "rebase", "cherry-pick" => "cherry-pick", "revert" => "revert", diff --git a/crates/strand-core/src/interchange.rs b/crates/strand-core/src/interchange.rs new file mode 100644 index 00000000..a5ef4194 --- /dev/null +++ b/crates/strand-core/src/interchange.rs @@ -0,0 +1,942 @@ +//! Explicit, previewed patch/mailbox/bundle interchange. Reads stay off snapshots. + +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Output, Stdio}, +}; + +const MAX_PATCH: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PatchTarget { + Worktree, + Index, + Both, + Mailbox, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatchPreview { + pub token: String, + pub paths: Vec, + pub messages: Vec, + pub valid: bool, + pub validation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MailboxState { + pub token: String, + pub current: String, + pub total: String, + pub author: String, + pub conflicts: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MailboxAction { + Continue, + Skip, + Abort, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterchangeOutcome { + pub success: bool, + pub paused: bool, + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleRef { + pub oid: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundlePreview { + pub token: String, + pub refs: Vec, + pub prerequisites: Vec, + pub valid: bool, + pub validation: String, +} + +// Private temporary directory, independent of filenames from imported content. +pub(crate) struct InterchangeScratch(pub PathBuf); +impl InterchangeScratch { + pub(crate) fn new() -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..100 { + let p = std::env::temp_dir().join(format!( + "strand-interchange-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&p) { + Ok(()) => return Ok(Self(p)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.into()), + } + } + Err(Error::Other( + "cannot allocate interchange scratch directory".into(), + )) + } +} +impl Drop for InterchangeScratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn git(cwd: &Path, args: &[&str], stdin: Option<&Path>, index: Option<&Path>) -> Result { + let mut cmd = crate::git_command(); + cmd.current_dir(cwd) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .env("GIT_EDITOR", "true") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()); + if let Some(p) = stdin { + cmd.stdin(fs::File::open(p)?); + } + if let Some(p) = index { + cmd.env("GIT_INDEX_FILE", p); + } + let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + fn drain(mut pipe: impl Read) -> std::io::Result> { + let mut captured = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = pipe.read(&mut buf)?; + if n == 0 { + break; + } + let keep = n.min((1024 * 1024usize).saturating_sub(captured.len())); + captured.extend_from_slice(&buf[..keep]); + } + Ok(captured) + } + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let reader = std::thread::spawn(move || drain(stdout)); + let stderr = drain(stderr)?; + let status = child.wait()?; + let stdout = reader + .join() + .map_err(|_| Error::Other("Git output reader failed".into()))??; + Ok(Output { + status, + stdout, + stderr, + }) +} + +fn diagnostic(out: &Output) -> String { + let mut bytes = out + .stdout + .iter() + .chain(out.stderr.iter()) + .take(64 * 1024) + .copied() + .collect::>(); + if out.stdout.len() + out.stderr.len() > bytes.len() { + bytes.extend_from_slice(b"\n[output truncated]"); + } + String::from_utf8_lossy(&bytes).trim().to_owned() +} +fn checked(out: Output) -> Result { + if out.status.success() { + Ok(out) + } else { + Err(Error::Other(diagnostic(&out))) + } +} +fn utf8_path(p: &Path) -> Result<&str> { + p.to_str() + .ok_or_else(|| Error::Other("path is not UTF-8".into())) +} +fn digest(bytes: &[u8]) -> Result { + Ok(git2::Oid::hash_object(git2::ObjectType::Blob, bytes)?.to_string()) +} +fn bounded_read(p: &Path, limit: u64) -> Result> { + let mut data = Vec::new(); + fs::File::open(p)?.take(limit + 1).read_to_end(&mut data)?; + if data.len() as u64 > limit { + return Err(Error::Other(format!( + "{} exceeds the {} MiB import limit", + p.display(), + limit / 1024 / 1024 + ))); + } + Ok(data) +} + +/// Concurrency stamp, not an authentication hash. Streams large bundle files. +fn file_stamp(p: &Path) -> Result { + use std::hash::Hasher; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + let mut file = fs::File::open(p)?; + let mut buf = [0u8; 65536]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hash.write(&buf[..n]); + } + Ok(format!("{:016x}", hash.finish())) +} + +impl Repo { + // Includes actual file bytes, because equal status rows do not mean equal content. + fn import_stamp(&self, paths: &[String], extra: &[u8]) -> Result { + let mut stamp = extra.to_vec(); + for name in [ + "HEAD", + "index", + "rebase-apply/next", + "rebase-apply/last", + "rebase-apply/info", + "rebase-apply/patch", + ] { + let p = self.git_dir().join(name); + if p.is_file() { + stamp.extend_from_slice(file_stamp(&p)?.as_bytes()); + } + } + stamp.extend_from_slice( + format!("{:?}", self.git2()?.head().ok().and_then(|h| h.target())).as_bytes(), + ); + for p in paths { + self.check_import_path(p)?; + stamp.extend_from_slice(p.as_bytes()); + let full = self.path.join(p); + if full.is_file() { + stamp.extend_from_slice(file_stamp(&full)?.as_bytes()); + } + } + digest(&stamp) + } + + /// Reject nonportable traversal, Git administrative paths, and symlink ancestors, + /// including dangling links and not-yet-created nested directories. + fn check_import_path(&self, path: &str) -> Result<()> { + if path.is_empty() || path.contains(['\\', ':', '\0']) || path.starts_with('/') { + return Err(Error::Other(format!("unsafe patch path: {path}"))); + } + let mut full = self.path.clone(); + for part in path.split('/') { + if part.is_empty() + || part == "." + || part == ".." + || part.eq_ignore_ascii_case(".git") + || part + .trim_end_matches([' ', '.']) + .eq_ignore_ascii_case(".git") + { + return Err(Error::Other(format!("unsafe patch path: {path}"))); + } + full.push(part); + match fs::symlink_metadata(&full) { + Ok(m) if m.file_type().is_symlink() => { + return Err(Error::Other(format!( + "patch path traverses a symlink: {path}" + ))) + } + Ok(_) => { + if !full.canonicalize()?.starts_with(self.path.canonicalize()?) { + return Err(Error::Other(format!( + "patch path escapes repository: {path}" + ))); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + fn patch_paths(&self, bytes: &[u8]) -> Result> { + let diff = git2::Diff::from_buffer(bytes)?; + let mut paths = BTreeSet::new(); + for delta in diff.deltas() { + for file in [delta.old_file(), delta.new_file()] { + // Applying links can change containment of a later patch in a series. + if file.mode() == git2::FileMode::Link { + return Err(Error::Other( + "importing symlink patches is not supported; inspect and apply with Git" + .into(), + )); + } + if let Some(p) = file.path() { + let p = utf8_path(p)?; + self.check_import_path(p)?; + paths.insert(p.to_owned()); + } + } + } + if paths.is_empty() { + return Err(Error::Other("patch contains no affected paths".into())); + } + Ok(paths.into_iter().collect()) + } + + pub fn preview_patch_import(&self, source: &Path, target: PatchTarget) -> Result { + if !source.is_absolute() { + return Err(Error::Other("patch source must be an absolute path".into())); + } + let bytes = bounded_read(source, MAX_PATCH)?; + self.preview_patch_bytes(&bytes, target) + } + + fn preview_patch_bytes(&self, bytes: &[u8], target: PatchTarget) -> Result { + let scratch = InterchangeScratch::new()?; + let input = scratch.0.join("input"); + fs::write(&input, bytes)?; + let mut paths = BTreeSet::new(); + let mut messages = Vec::new(); + let mut valid = true; + let mut validation = String::new(); + if target == PatchTarget::Mailbox { + let maildir = scratch.0.join("mail"); + fs::create_dir(&maildir)?; + let outdir = format!("-o{}", utf8_path(&maildir)?); + let split = checked(git( + &self.path, + &["mailsplit", "-b", &outdir, "--", utf8_path(&input)?], + None, + None, + )?)?; + let count: usize = String::from_utf8_lossy(&split.stdout) + .trim() + .parse() + .map_err(|_| Error::Other("invalid mailbox count".into()))?; + if count == 0 || count > 1000 { + return Err(Error::Other("mailbox must contain 1–1000 patches".into())); + } + let index = scratch.0.join("index"); + checked(git(&self.path, &["read-tree", "HEAD"], None, Some(&index))?)?; + for n in 1..=count { + let mail = maildir.join(format!("{n:04}")); + let patch = scratch.0.join("patch"); + let message = scratch.0.join("message"); + let info = checked(git( + &self.path, + &["mailinfo", utf8_path(&message)?, utf8_path(&patch)?], + Some(&mail), + None, + )?)?; + messages.push(diagnostic(&info)); + paths.extend(self.patch_paths(&bounded_read(&patch, MAX_PATCH)?)?); + if valid { + let out = git( + &self.path, + &["apply", "--cached", "--", utf8_path(&patch)?], + None, + Some(&index), + )?; + if !out.status.success() { + valid = false; + validation = format!("Patch {n}: {}", diagnostic(&out)); + } + } + } + } else { + paths.extend(self.patch_paths(bytes)?); + let mut args = vec!["apply", "--check"]; + match target { + PatchTarget::Index => args.push("--cached"), + PatchTarget::Both => args.push("--index"), + _ => {} + } + args.extend(["--", utf8_path(&input)?]); + let out = git(&self.path, &args, None, None)?; + valid = out.status.success(); + validation = diagnostic(&out); + } + let paths: Vec<_> = paths.into_iter().collect(); + let token = + self.import_stamp(&paths, format!("{target:?}:{}", digest(bytes)?).as_bytes())?; + Ok(PatchPreview { + token, + paths, + messages, + valid, + validation, + }) + } + + pub fn import_patch( + &self, + source: &Path, + target: PatchTarget, + token: &str, + ) -> Result { + if !source.is_absolute() { + return Err(Error::Other("patch source must be an absolute path".into())); + } + if let Some(op) = self.operation_in_progress() { + return Err(Error::Other(format!( + "finish or abort {op} before importing" + ))); + } + let bytes = bounded_read(source, MAX_PATCH)?; + let preview = self.preview_patch_bytes(&bytes, target)?; + if preview.token != token { + return Err(Error::Other( + "patch or repository changed; preview again".into(), + )); + } + if !preview.valid && target != PatchTarget::Mailbox { + return Err(Error::Other(preview.validation)); + } + let scratch = InterchangeScratch::new()?; + let input = scratch.0.join("input"); + fs::write(&input, bytes)?; + let mut args = match target { + PatchTarget::Mailbox => { + let out = checked(git( + &self.path, + &["status", "--porcelain", "--untracked-files=normal"], + None, + None, + )?)?; + if !out.stdout.is_empty() { + return Err(Error::Other("mailbox import requires a clean index and working tree; commit or stash changes first".into())); + } + vec!["am", "--3way"] + } + PatchTarget::Index => vec!["apply", "--cached"], + PatchTarget::Both => vec!["apply", "--index"], + PatchTarget::Worktree => vec!["apply"], + }; + args.extend(["--", utf8_path(&input)?]); + let out = git(&self.path, &args, None, None)?; + Ok(InterchangeOutcome { + success: out.status.success(), + paused: self.operation_in_progress().as_deref() == Some("mailbox"), + output: diagnostic(&out), + }) + } + + pub fn mailbox_state(&self) -> Result> { + if self.operation_in_progress().as_deref() != Some("mailbox") { + return Ok(None); + } + let dir = self.git_dir().join("rebase-apply"); + let read = |n: &str| -> Result { + Ok( + String::from_utf8_lossy(&bounded_read(&dir.join(n), MAX_PATCH)?) + .trim() + .to_owned(), + ) + }; + let mut index = self.git2()?.index()?; + index.read(true)?; + Ok(Some(MailboxState { + token: self.import_stamp(&[], &[])?, + current: read("next")?, + total: read("last")?, + author: read("info").unwrap_or_default(), + conflicts: index.has_conflicts(), + })) + } + + pub fn mailbox_action(&self, action: MailboxAction, token: &str) -> Result { + let state = self + .mailbox_state()? + .ok_or_else(|| Error::Other("no mailbox operation in progress".into()))?; + if state.token != token { + return Err(Error::Other( + "mailbox state changed; refresh before continuing".into(), + )); + } + if matches!(action, MailboxAction::Continue) && state.conflicts { + return Err(Error::Other( + "resolve and stage every conflict first".into(), + )); + } + let arg = match action { + MailboxAction::Continue => "--continue", + MailboxAction::Skip => "--skip", + MailboxAction::Abort => "--abort", + }; + let out = git(&self.path, &["am", arg], None, None)?; + Ok(InterchangeOutcome { + success: out.status.success(), + paused: self.operation_in_progress().as_deref() == Some("mailbox"), + output: diagnostic(&out), + }) + } + + pub fn preview_bundle(&self, source: &Path) -> Result { + if !source.is_absolute() { + return Err(Error::Other( + "bundle source must be an absolute path".into(), + )); + } + // Header is bounded even though pack data can be arbitrarily large. + let mut header = Vec::new(); + use std::io::BufRead; + let mut input = std::io::BufReader::new(fs::File::open(source)?); + loop { + let mut line = Vec::new(); + input + .by_ref() + .take(1024 * 1024 + 1) + .read_until(b'\n', &mut line)?; + if line.is_empty() { + return Err(Error::Other("incomplete bundle header".into())); + } + if header.len() + line.len() > 1024 * 1024 { + return Err(Error::Other("bundle header exceeds 1 MiB".into())); + } + header.extend_from_slice(&line); + if line == b"\n" { + break; + } + } + let text = String::from_utf8_lossy(&header); + let prerequisites = text + .lines() + .filter_map(|l| l.strip_prefix('-').map(str::to_owned)) + .collect(); + let heads = checked(git( + &self.path, + &["bundle", "list-heads", utf8_path(source)?], + None, + None, + )?)?; + let refs = String::from_utf8_lossy(&heads.stdout) + .lines() + .filter_map(|l| { + l.split_once(' ').map(|(oid, name)| BundleRef { + oid: oid.into(), + name: name.into(), + }) + }) + .collect(); + let out = git( + &self.path, + &["bundle", "verify", utf8_path(source)?], + None, + None, + )?; + Ok(BundlePreview { + token: file_stamp(source)?, + refs, + prerequisites, + valid: out.status.success(), + validation: diagnostic(&out), + }) + } + + /// Fetch exactly one reviewed bundle ref into a new local branch. Existing + /// refs/HEAD are never overwritten and Git owns object/prerequisite validation. + pub fn import_bundle( + &self, + source: &Path, + token: &str, + source_ref: &str, + branch: &str, + ) -> Result { + let scratch = InterchangeScratch::new()?; + let copy = scratch.0.join("input.bundle"); + fs::copy(source, ©)?; + let preview = self.preview_bundle(©)?; + if preview.token != token { + return Err(Error::Other("bundle changed; verify again".into())); + } + if !preview.valid { + return Err(Error::Other(preview.validation)); + } + let selected = preview + .refs + .iter() + .find(|r| r.name == source_ref) + .ok_or_else(|| Error::Other("choose an advertised bundle ref".into()))?; + let dest = format!("refs/heads/{branch}"); + if branch.starts_with('-') || !git2::Reference::is_valid_name(&dest) { + return Err(Error::Other("invalid destination branch".into())); + } + // Import objects without publishing a ref, then use a compare-and-swap + // create. A concurrent external branch creation must never be overwritten. + checked(git( + &self.path, + &["bundle", "unbundle", utf8_path(©)?], + None, + None, + )?)?; + let commit = self + .git2()? + .find_object(git2::Oid::from_str(&selected.oid)?, None)? + .peel_to_commit()? + .id(); + self.git2()? + .reference(&dest, commit, false, "Strand bundle import")?; + Ok(InterchangeOutcome { + success: true, + paused: false, + output: format!("Imported {} at {} into {dest}", selected.name, selected.oid), + }) + } + + /// Export a named ref, optionally excluding a prerequisite revision. Verify + /// the exported tip before publishing, so concurrent ref updates fail closed. + pub fn export_bundle( + &self, + destination: &Path, + refname: &str, + prerequisite: Option<&str>, + ) -> Result { + if !destination.is_absolute() || destination.exists() { + return Err(Error::Other( + "choose a new absolute bundle destination; existing files are never overwritten" + .into(), + )); + } + let parent = destination + .parent() + .ok_or_else(|| Error::Other("destination has no parent".into()))? + .canonicalize()?; + if parent.starts_with(self.git_dir().canonicalize()?) + || parent.starts_with(self.gix.common_dir().canonicalize()?) + { + return Err(Error::Other( + "bundle destination cannot be inside Git administrative directories".into(), + )); + } + let reference = self.git2()?.find_reference(refname)?; + let tip = reference.peel_to_commit()?.id().to_string(); + let base = prerequisite + .map(|r| { + self.git2()? + .revparse_single(r)? + .peel_to_commit() + .map(|c| format!("^{}", c.id())) + .map_err(Error::from) + }) + .transpose()?; + // git bundle requires a named positive ref. Verify it again after creation + // and fail without publishing the artifact if it moved during export. + let scratch = InterchangeScratch::new()?; + let output = scratch.0.join("export.bundle"); + let mut args = vec!["bundle", "create", utf8_path(&output)?, refname]; + if let Some(b) = base.as_deref() { + args.push(b); + } + checked(git(&self.path, &args, None, None)?)?; + let preview = self.preview_bundle(&output)?; + if !preview + .refs + .iter() + .any(|r| r.name == refname && r.oid == tip) + { + return Err(Error::Other("export ref changed; retry".into())); + } + let mut dest = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + if let Err(e) = + std::io::copy(&mut fs::File::open(output)?, &mut dest).and_then(|_| dest.flush()) + { + drop(dest); + let _ = fs::remove_file(destination); + return Err(e.into()); + } + Ok(preview) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (InterchangeScratch, Repo) { + let scratch = InterchangeScratch::new().unwrap(); + let repo = git2::Repository::init(&scratch.0).unwrap(); + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Committer").unwrap(); + config + .set_str("user.email", "committer@example.test") + .unwrap(); + config.set_bool("commit.gpgsign", false).unwrap(); + config.set_bool("core.autocrlf", false).unwrap(); + config.set_str("core.hooksPath", "/dev/null").unwrap(); + fs::write(scratch.0.join("file.txt"), "one\n").unwrap(); + checked(git(&scratch.0, &["add", "."], None, None).unwrap()).unwrap(); + checked(git(&scratch.0, &["commit", "-m", "base"], None, None).unwrap()).unwrap(); + (scratch, Repo::discover(repo.workdir().unwrap()).unwrap()) + } + fn patch() -> &'static str { + "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-one\n+two\n" + } + fn input(s: &InterchangeScratch, data: &str) -> PathBuf { + let p = s.0.join("input.patch"); + fs::write(&p, data).unwrap(); + p + } + + #[test] + fn patch_targets_validation_and_stale_preview() { + for target in [PatchTarget::Worktree, PatchTarget::Index, PatchTarget::Both] { + let (_s, repo) = fixture(); + let input_dir = InterchangeScratch::new().unwrap(); + let p = input(&input_dir, patch()); + let preview = repo.preview_patch_import(&p, target).unwrap(); + assert!(preview.valid, "{}", preview.validation); + assert_eq!(preview.paths, ["file.txt"]); + assert!( + repo.import_patch(&p, target, &preview.token) + .unwrap() + .success + ); + let wt = fs::read_to_string(repo.path.join("file.txt")).unwrap(); + assert_eq!( + wt, + if target == PatchTarget::Index { + "one\n" + } else { + "two\n" + } + ); + let staged = + checked(git(&repo.path, &["show", ":file.txt"], None, None).unwrap()).unwrap(); + assert_eq!( + staged.stdout, + if target == PatchTarget::Worktree { + b"one\n" + } else { + b"two\n" + } + ); + assert!(repo.import_patch(&p, target, &preview.token).is_err()); + } + let (_s, repo) = fixture(); + let s = InterchangeScratch::new().unwrap(); + let p = input(&s, patch()); + let preview = repo + .preview_patch_import(&p, PatchTarget::Worktree) + .unwrap(); + fs::write(repo.path.join("file.txt"), "external edit\n").unwrap(); + assert!(repo + .import_patch(&p, PatchTarget::Worktree, &preview.token) + .is_err()); + assert!( + !repo + .preview_patch_import(&p, PatchTarget::Worktree) + .unwrap() + .valid + ); + } + + #[test] + fn reject_path_traversal_admin_and_nested_symlinks() { + let (_s, repo) = fixture(); + for path in [ + "../outside", + "/absolute", + ".git/config", + "C:/outside", + "dir/../../out", + "dir\\..\\out", + ] { + assert!(repo.check_import_path(path).is_err(), "{path}"); + } + assert!(repo.check_import_path("new/nested/file").is_ok()); + let s = InterchangeScratch::new().unwrap(); + let p = input(&s, &patch().replace("file.txt", "../outside")); + assert!(repo + .preview_patch_import(&p, PatchTarget::Worktree) + .is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&s.0, repo.path.join("link")).unwrap(); + assert!(repo.check_import_path("link/new/file").is_err()); + } + } + + fn mailbox(repo: &Repo, dir: &InterchangeScratch) -> PathBuf { + fs::write(repo.path.join("file.txt"), "two\n").unwrap(); + checked( + git( + &repo.path, + &[ + "commit", + "-am", + "authored change", + "--author=Original Author ", + ], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + let output = checked( + git( + &repo.path, + &["format-patch", "--stdout", "-1", "HEAD"], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + checked(git(&repo.path, &["reset", "--hard", "HEAD~1"], None, None).unwrap()).unwrap(); + let p = dir.0.join("mailbox"); + fs::write(&p, output.stdout).unwrap(); + p + } + + #[test] + fn mailbox_preserves_authors_and_recovers_continue_skip_abort() { + for action in [ + MailboxAction::Continue, + MailboxAction::Skip, + MailboxAction::Abort, + ] { + let (_s, repo) = fixture(); + let dir = InterchangeScratch::new().unwrap(); + let p = mailbox(&repo, &dir); + fs::write(repo.path.join("file.txt"), "diverged\n").unwrap(); + checked(git(&repo.path, &["commit", "-am", "divergence"], None, None).unwrap()) + .unwrap(); + let original = repo.git2().unwrap().head().unwrap().target().unwrap(); + let preview = repo.preview_patch_import(&p, PatchTarget::Mailbox).unwrap(); + assert!(preview.messages[0].contains("Original Author")); + let outcome = repo + .import_patch(&p, PatchTarget::Mailbox, &preview.token) + .unwrap(); + assert!(outcome.paused, "{}", outcome.output); + assert_eq!( + Repo::discover(&repo.path) + .unwrap() + .meta() + .unwrap() + .operation + .as_deref(), + Some("mailbox") + ); + let before = repo.mailbox_state().unwrap().unwrap(); + assert!(before.author.contains("Original Author")); + if matches!(action, MailboxAction::Continue) { + fs::write(repo.path.join("file.txt"), "resolved\n").unwrap(); + checked(git(&repo.path, &["add", "file.txt"], None, None).unwrap()).unwrap(); + assert!(repo.mailbox_action(action, &before.token).is_err()); + } + let state = repo.mailbox_state().unwrap().unwrap(); + let outcome = repo.mailbox_action(action, &state.token).unwrap(); + assert!(outcome.success && !outcome.paused, "{}", outcome.output); + let fresh = git2::Repository::open(&repo.path).unwrap(); + let head = fresh.head().unwrap().peel_to_commit().unwrap(); + if matches!(action, MailboxAction::Continue) { + assert_eq!(head.author().email(), Some("author@example.test")); + } else { + assert_eq!(head.id(), original); + } + } + } + + #[test] + fn clean_mailbox_series_previews_every_author_and_applies_in_order() { + let (_s, repo) = fixture(); + let dir = InterchangeScratch::new().unwrap(); + for (text, author) in [ + ("two\n", "First "), + ("three\n", "Second "), + ] { + fs::write(repo.path.join("file.txt"), text).unwrap(); + checked( + git( + &repo.path, + &["commit", "-am", "series", &format!("--author={author}")], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + } + let out = checked( + git( + &repo.path, + &["format-patch", "--stdout", "-2", "HEAD"], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + checked(git(&repo.path, &["reset", "--hard", "HEAD~2"], None, None).unwrap()).unwrap(); + let p = dir.0.join("series"); + fs::write(&p, out.stdout).unwrap(); + let preview = repo.preview_patch_import(&p, PatchTarget::Mailbox).unwrap(); + assert!(preview.valid, "{}", preview.validation); + assert_eq!(preview.messages.len(), 2); + assert!(preview.messages[0].contains("first@example.test")); + assert!(preview.messages[1].contains("second@example.test")); + assert!( + repo.import_patch(&p, PatchTarget::Mailbox, &preview.token) + .unwrap() + .success + ); + assert_eq!( + fs::read_to_string(repo.path.join("file.txt")).unwrap(), + "three\n" + ); + assert!(repo.mailbox_state().unwrap().is_none()); + } + + #[test] + fn bundles_verify_prerequisites_and_never_overwrite_refs() { + let (_s, repo) = fixture(); + let dir = InterchangeScratch::new().unwrap(); + let full = dir.0.join("full.bundle"); + let refname = repo + .git2() + .unwrap() + .head() + .unwrap() + .name() + .unwrap() + .to_owned(); + let full_preview = repo.export_bundle(&full, &refname, None).unwrap(); + assert!(full_preview.valid && full_preview.prerequisites.is_empty()); + let (_r, receiver) = fixture(); + let outcome = receiver + .import_bundle(&full, &full_preview.token, &refname, "imported") + .unwrap(); + assert!(outcome.success); + assert!(receiver + .import_bundle(&full, &full_preview.token, &refname, "imported") + .is_err()); + assert!(repo.export_bundle(&full, &refname, None).is_err()); + fs::write(repo.path.join("file.txt"), "next\n").unwrap(); + checked(git(&repo.path, &["commit", "-am", "next"], None, None).unwrap()).unwrap(); + let incremental = dir.0.join("incremental.bundle"); + let preview = repo + .export_bundle(&incremental, &refname, Some("HEAD~1")) + .unwrap(); + assert_eq!(preview.prerequisites.len(), 1); + let empty = InterchangeScratch::new().unwrap(); + git2::Repository::init(&empty.0).unwrap(); + let empty_repo = Repo::discover(&empty.0).unwrap(); + assert!(!empty_repo.preview_bundle(&incremental).unwrap().valid); + assert!(empty_repo + .import_bundle(&incremental, &preview.token, &refname, "missing") + .is_err()); + } +} diff --git a/crates/strand-core/src/lfs.rs b/crates/strand-core/src/lfs.rs new file mode 100644 index 00000000..69041734 --- /dev/null +++ b/crates/strand-core/src/lfs.rs @@ -0,0 +1,540 @@ +//! Git LFS operations are explicit, lazy, and delegated to the installed Git LFS. + +use crate::{ + network::{run_git_streaming, CancelHandle, NetworkOutcome, Progress}, + Error, Repo, Result, +}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "kebab-case")] +pub enum LfsAction { + Environment, + Install, + Patterns, + Track { pattern: String }, + Untrack { pattern: String }, + Status, + Objects, + Fetch { remote: String }, + Pull { remote: String }, + Push { remote: String }, + Locks { path: String }, + Lock { path: String }, + Unlock { id: String }, +} + +fn argument(value: &str, label: &str) -> Result<()> { + if value.trim().is_empty() || value.starts_with('-') || value.contains(['\0', '\n', '\r']) { + return Err(Error::Other(format!( + "Enter a non-empty {label} that does not start with '-' or contain line breaks." + ))); + } + Ok(()) +} + +impl Repo { + /// No LFS subprocesses run in snapshots or ordinary status refreshes. + pub fn lfs_action( + &self, + action: LfsAction, + progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result { + let mut args = vec!["lfs".to_string()]; + match action { + LfsAction::Environment => args.push("env".into()), + LfsAction::Install => args.extend(["install".into(), "--local".into()]), + LfsAction::Patterns => args.push("track".into()), + LfsAction::Track { pattern } => { + argument(&pattern, "tracking pattern")?; + args.extend(["track".into(), "--".into(), pattern]); + } + LfsAction::Untrack { pattern } => { + argument(&pattern, "tracking pattern")?; + args.extend(["untrack".into(), "--".into(), pattern]); + } + LfsAction::Status => args.push("status".into()), + LfsAction::Objects => args.extend(["ls-files".into(), "--size".into()]), + LfsAction::Fetch { remote } => { + argument(&remote, "remote")?; + args.extend(["fetch".into(), remote]); + } + LfsAction::Pull { remote } => { + argument(&remote, "remote")?; + args.extend(["pull".into(), remote]); + } + LfsAction::Push { remote } => { + argument(&remote, "remote")?; + args.extend(["push".into(), remote]); + } + LfsAction::Locks { path } => { + args.extend(["locks".into(), "--limit=100".into()]); + if !path.is_empty() { + argument(&path, "lock path")?; + args.push(format!("--path={path}")); + } + } + LfsAction::Lock { path } => { + argument(&path, "file path")?; + args.extend(["lock".into(), "--".into(), path]); + } + LfsAction::Unlock { id } => { + argument(&id, "lock ID")?; + args.extend(["unlock".into(), format!("--id={id}")]); + } + } + run_git_streaming(&self.path, &args.iter().map(String::as_str).collect::>(), progress, cancel) + .map_err(|error| match error { + Error::Cancelled => Error::Cancelled, + other => Error::Other(format!("{other}\nCheck Git LFS installation, repository setup and remote access, then retry. Completed objects are retained; history is never migrated.")), + }) + } + + pub(crate) fn is_lfs_path(&self, path: &Path) -> Result { + Ok(self + .git2()? + .get_attr(path, "filter", git2::AttrCheckFlags::FILE_THEN_INDEX)? + == Some("lfs")) + } + + fn require_lfs_filter(&self) -> Result<()> { + let config = self.git2()?.config()?; + if !config + .get_string("filter.lfs.process") + .is_ok_and(|v| !v.trim().is_empty()) + && !config + .get_string("filter.lfs.clean") + .is_ok_and(|v| !v.trim().is_empty()) + { + return Err(Error::Other("LFS filters are not configured. Open Git LFS → Set up this repository, then retry.".into())); + } + Ok(()) + } + + pub(crate) fn stage_lfs_paths(&self, paths: &[String]) -> Result<()> { + self.run_lfs_paths( + &[ + "--literal-pathspecs", + "add", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + paths.iter().map(String::as_str), + ) + } + + pub(crate) fn discard_lfs_paths(&self, paths: &[&str]) -> Result<()> { + self.run_lfs_paths( + &["checkout-index", "--force", "-z", "--stdin"], + paths.iter().copied(), + ) + } + + fn run_lfs_paths<'a>(&self, args: &[&str], paths: impl Iterator) -> Result<()> { + self.require_lfs_filter()?; + let mut input = Vec::new(); + for path in paths { + if path.contains('\0') { + return Err(Error::Other("Invalid file path".into())); + } + input.extend_from_slice(path.as_bytes()); + input.push(0); + } + let mut filtered = vec!["-c", "filter.lfs.required=true"]; + filtered.extend_from_slice(args); + let transcript = crate::network::run_git_input_transcript( + &self.path, + &filtered, + Some(input), + |_| {}, + None, + )?; + if !transcript.success { + return Err(Error::Other(transcript.output)); + } + if self.sparse_enabled() { + self.sparse_read_index(self.git2()?)?; + } else { + self.git2()?.index()?.read(true)?; + } + Ok(()) + } + + pub(crate) fn lfs_checkout_needed(&self, tree: &git2::Tree<'_>) -> Result { + let repo = self.git2()?; + for entry in repo.index()?.iter() { + if let Ok(path) = std::str::from_utf8(&entry.path) { + if self.is_lfs_path(Path::new(path))? { + return Ok(true); + } + } + } + let mut found = false; + let mut attribute_error = None; + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() == Some(git2::ObjectType::Blob) { + if let Some(name) = entry.name() { + match self.is_lfs_path(Path::new(&format!("{root}{name}"))) { + Ok(lfs) => found |= lfs, + Err(error) => attribute_error = Some(error), + } + } + } + if entry.name() == Some(".gitattributes") { + if let Ok(blob) = repo.find_blob(entry.id()) { + found |= String::from_utf8_lossy(blob.content()).contains("filter=lfs"); + } + } + if found { + git2::TreeWalkResult::Skip + } else { + git2::TreeWalkResult::Ok + } + })?; + if let Some(error) = attribute_error { + return Err(error); + } + Ok(found) + } + + pub(crate) fn run_lfs_filtered(&self, args: &[&str]) -> Result<()> { + self.require_lfs_filter()?; + let mut filtered = vec!["-c", "filter.lfs.required=true"]; + filtered.extend_from_slice(args); + run_git_streaming(&self.path, &filtered, |_| {}, None)?; + // In-process fixtures and chained operations may reuse this handle. + if self.sparse_enabled() { + self.sparse_read_index(self.git2()?)?; + } else { + self.git2()?.index()?.read(true)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::LfsAction; + use crate::Repo; + use std::{ + path::{Path, PathBuf}, + process::Command, + }; + + fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap().trim().to_string() + } + + fn fixture() -> (Repo, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "strand-lfs-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-q", "-b", "main"]); + git(&dir, &["config", "user.name", "Test"]); + git(&dir, &["config", "user.email", "test@example.com"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + git(&dir, &["lfs", "install", "--local"]); + git(&dir, &["lfs", "track", "*.bin"]); + (Repo::discover(&dir).unwrap(), dir) + } + + #[test] + fn sparse_index_staging_discard_and_partial_patch_keep_lfs_guards() { + let (repo, dir) = fixture(); + std::fs::create_dir_all(dir.join("assets")).unwrap(); + std::fs::create_dir_all(dir.join("excluded")).unwrap(); + std::fs::write(dir.join("assets/one.bin"), b"first asset\n").unwrap(); + std::fs::write(dir.join("excluded/file.txt"), b"excluded\n").unwrap(); + git(&dir, &["add", "."]); + repo.commit("base", None, false).unwrap(); + git(&dir, &["sparse-checkout", "set", "--cone", "--sparse-index", "assets"]); + let repo = Repo::discover(&dir).unwrap(); + std::fs::write(dir.join("assets/one.bin"), b"second asset\n").unwrap(); + repo.stage_path("assets/one.bin").unwrap(); + let pointer = git(&dir, &["show", ":assets/one.bin"]); + assert!(pointer.starts_with("version https://git-lfs.github.com/spec/v1\n")); + std::fs::write(dir.join("assets/one.bin"), b"discard this\n").unwrap(); + repo.discard_path("assets/one.bin").unwrap(); + assert_eq!(std::fs::read(dir.join("assets/one.bin")).unwrap(), b"second asset\n"); + let patch = git(&dir, &["diff", "--cached", "--", "assets/one.bin"]); + assert!(repo.apply_patch(&(patch + "\n"), crate::apply::ApplyTarget::IndexReverse) + .unwrap_err().to_string().contains("whole file")); + assert_eq!(git(&dir, &["show", ":assets/one.bin"]), pointer); + assert!(git(&dir, &["ls-files", "--sparse", "-t"]).contains("S excluded/")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn single_and_bulk_stage_store_real_lfs_pointers() { + let (repo, dir) = fixture(); + std::fs::write(dir.join("one.bin"), b"large content\0one\n").unwrap(); + repo.stage_path("one.bin").unwrap(); + let expected = git(&dir, &["hash-object", "--path=one.bin", "one.bin"]); + assert_eq!(git(&dir, &["rev-parse", ":one.bin"]), expected); + let pointer = git(&dir, &["show", ":one.bin"]); + assert!( + pointer.starts_with("version https://git-lfs.github.com/spec/v1\noid sha256:"), + "{pointer}" + ); + std::fs::write(dir.join("two.bin"), b"large content\0two\n").unwrap(); + repo.stage_paths(&[".gitattributes".into(), "one.bin".into(), "two.bin".into()]) + .unwrap(); + let two = git(&dir, &["show", ":two.bin"]); + assert!(two.starts_with("version https://git-lfs.github.com/spec/v1\noid sha256:")); + repo.commit("LFS assets", None, false).unwrap(); + assert_eq!(git(&dir, &["show", "HEAD:one.bin"]), pointer); + assert_eq!(git(&dir, &["show", "HEAD:two.bin"]), two); + assert!( + repo.status().unwrap().is_empty(), + "clean LFS files must not appear modified" + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn checkout_discard_and_network_round_trip_content_and_pointer_bytes() { + let (repo, dir) = fixture(); + let original = b"large content\0first\n"; + let changed = b"large content\0second\n"; + std::fs::write(dir.join("asset.bin"), original).unwrap(); + repo.stage_paths(&[".gitattributes".into(), "asset.bin".into()]) + .unwrap(); + let first = repo.commit("first", None, false).unwrap().oid; + repo.create_branch("next", None, true).unwrap(); + std::fs::write(dir.join("asset.bin"), changed).unwrap(); + repo.stage_path("asset.bin").unwrap(); + let second = repo.commit("second", None, false).unwrap().oid; + let pointer = git(&dir, &["show", "HEAD:asset.bin"]); + repo.checkout_branch("main").unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), original); + repo.checkout_branch("next").unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), changed); + std::fs::write(dir.join("asset.bin"), b"uncommitted").unwrap(); + assert!(repo.checkout_branch("main").is_err()); + assert_eq!( + std::fs::read(dir.join("asset.bin")).unwrap(), + b"uncommitted" + ); + repo.discard_path("asset.bin").unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), changed); + repo.checkout_commit(&first).unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), original); + repo.checkout_branch("next").unwrap(); + + repo.reset(&first, crate::reset::ResetMode::Hard).unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), original); + repo.reset(&second, crate::reset::ResetMode::Hard).unwrap(); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), changed); + + let remote = dir.join("upstream.git"); + git(&dir, &["init", "--bare", remote.to_str().unwrap()]); + git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]); + repo.push_current_to_remote("origin", true, |_| {}, None) + .unwrap(); + let consumer = dir.join("consumer"); + // Git LFS 3.5 installs post-checkout during smudge, which newer Git's + // clone protection refuses. Keep clone configuration in F09's scope: + // acquire objects without checkout, then exercise Strand's checkout. + git( + &dir, + &[ + "clone", + "--no-checkout", + "--branch", + "next", + remote.to_str().unwrap(), + consumer.to_str().unwrap(), + ], + ); + Repo::discover(&consumer) + .unwrap() + .checkout_branch("next") + .unwrap(); + assert_eq!(std::fs::read(consumer.join("asset.bin")).unwrap(), changed); + assert_eq!(git(&consumer, &["show", "HEAD:asset.bin"]), pointer); + std::fs::write(dir.join("asset.bin"), b"third\0version").unwrap(); + repo.stage_path("asset.bin").unwrap(); + repo.commit("third", None, false).unwrap(); + repo.push_current_to_remote("origin", true, |_| {}, None) + .unwrap(); + Repo::discover(&consumer) + .unwrap() + .pull( + crate::network::PullMode::FastForwardOnly, + false, + |_| {}, + None, + ) + .unwrap(); + assert_eq!( + std::fs::read(consumer.join("asset.bin")).unwrap(), + b"third\0version" + ); + assert_eq!( + git(&consumer, &["show", "HEAD:asset.bin"]), + git(&dir, &["show", "HEAD:asset.bin"]) + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_filter_fails_without_writing_raw_bytes_or_losing_bulk_index() { + let (repo, dir) = fixture(); + std::fs::write(dir.join("asset.bin"), b"content\0").unwrap(); + std::fs::write(dir.join("keep.txt"), b"keep").unwrap(); + repo.stage_path("keep.txt").unwrap(); + let before = git(&dir, &["write-tree"]); + git( + &dir, + &[ + "config", + "filter.lfs.process", + "strand-missing-git-lfs filter-process", + ], + ); + assert!(repo.stage_path("asset.bin").is_err()); + assert!(repo + .stage_paths(&["keep.txt".into(), "asset.bin".into()]) + .is_err()); + assert_eq!(git(&dir, &["write-tree"]), before); + assert_eq!(std::fs::read(dir.join("asset.bin")).unwrap(), b"content\0"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn management_tracks_without_history_migration_and_reports_server_locks() { + use std::io::{Read, Write}; + let (repo, dir) = fixture(); + repo.stage_path(".gitattributes").unwrap(); + let head = repo.commit("attributes", None, false).unwrap().oid; + repo.lfs_action(LfsAction::Install, |_| {}, None).unwrap(); + repo.lfs_action( + LfsAction::Track { + pattern: "*.psd".into(), + }, + |_| {}, + None, + ) + .unwrap(); + assert!(repo + .lfs_action(LfsAction::Patterns, |_| {}, None) + .unwrap() + .output + .contains("*.psd")); + repo.lfs_action( + LfsAction::Untrack { + pattern: "*.psd".into(), + }, + |_| {}, + None, + ) + .unwrap(); + assert!(!std::fs::read_to_string(dir.join(".gitattributes")) + .unwrap() + .contains("*.psd")); + assert_eq!(git(&dir, &["rev-parse", "HEAD"]), head); + for action in [ + LfsAction::Environment, + LfsAction::Status, + LfsAction::Objects, + ] { + repo.lfs_action(action, |_| {}, None).unwrap(); + } + let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + server.set_nonblocking(true).unwrap(); + git( + &dir, + &[ + "config", + "lfs.url", + &format!("http://{}", server.local_addr().unwrap()), + ], + ); + let worker = std::thread::spawn(move || { + let lock = r#"{"id":"1","path":"asset.bin","locked_at":"2026-09-06T10:00:00Z","owner":{"name":"Test"}}"#; + for (request, body) in [ + ("POST /locks", format!("{{\"lock\":{lock}}}")), + ("GET /locks?", format!("{{\"locks\":[{lock}]}}")), + ("POST /locks/1/unlock", format!("{{\"lock\":{lock}}}")), + ] { + let started = std::time::Instant::now(); + let mut stream = loop { + match server.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + started.elapsed().as_secs() < 120, + "LFS did not contact lock server" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => panic!("{e}"), + } + }; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(30))) + .unwrap(); + let mut received = Vec::new(); + let mut byte = [0]; + while !received.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + received.push(byte[0]); + assert!(received.len() < 16_384); + } + assert!( + String::from_utf8_lossy(&received).starts_with(request), + "{}", + String::from_utf8_lossy(&received) + ); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.git-lfs+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + std::fs::write(dir.join("asset.bin"), b"content").unwrap(); + repo.lfs_action( + LfsAction::Lock { + path: "asset.bin".into(), + }, + |_| {}, + None, + ) + .unwrap(); + assert!(repo + .lfs_action( + LfsAction::Locks { + path: String::new() + }, + |_| {}, + None + ) + .unwrap() + .output + .contains("asset.bin")); + repo.lfs_action(LfsAction::Unlock { id: "1".into() }, |_| {}, None) + .unwrap(); + worker.join().unwrap(); + let cancel = crate::network::CancelHandle::new(); + cancel.cancel(); + assert!(matches!( + repo.lfs_action(LfsAction::Status, |_| {}, Some(&cancel)), + Err(crate::Error::Cancelled) + )); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index e2dc385a..caa4b61f 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -21,16 +21,25 @@ pub mod log; pub mod diff; pub mod stage; pub mod apply; +pub mod interchange; +pub mod advanced_refs; +pub mod bisect; pub mod commit; +pub mod signing; pub mod commit_metadata; pub mod network; pub mod refs; pub mod branch; pub mod remote; pub mod maintenance; +pub mod lfs; pub mod conflict; pub mod external; +pub mod user_actions; pub mod gitconfig; +pub mod gitflow; + +mod git_output; pub mod history; pub mod ignore; pub mod stash; @@ -45,6 +54,7 @@ pub mod reflog; pub mod rename; pub mod reset; pub mod snapshot; +pub mod sparse; pub mod watch; pub use error::{Error, Result}; diff --git a/crates/strand-core/src/network.rs b/crates/strand-core/src/network.rs index dbd5eb8b..9c30ce61 100644 --- a/crates/strand-core/src/network.rs +++ b/crates/strand-core/src/network.rs @@ -13,7 +13,7 @@ //! callback to an IPC `Channel` so the UI can show a live progress bar; the //! core stays UI-agnostic. -use std::io::Read; +use std::io::{Read, Write}; use std::path::Path; use std::process::Stdio; use std::sync::{Arc, Mutex}; @@ -43,8 +43,14 @@ impl CancelHandle { pub fn cancel(&self) { let mut inner = self.0.lock().expect("cancel handle lock"); inner.cancelled = true; - if let Some(child) = inner.child.as_mut() { - let _ = child.kill(); + if inner.child.is_some() { + let handle = self.clone(); + // Recursive clones have child Git/SSH processes holding the pipes. + // Kill their tree off the IPC thread so cancellation stays immediate. + std::thread::spawn(move || { + let mut inner = handle.0.lock().expect("cancel handle lock"); + if let Some(child) = inner.child.as_mut() { kill_git_tree(child); } + }); } } @@ -53,6 +59,23 @@ impl CancelHandle { } } +// Git LFS and submodule helpers inherit the pipes. Killing only git can leave +// those helpers transferring (and the reader waiting for EOF) after Cancel. +fn kill_git_tree(child: &mut std::process::Child) { + if matches!(child.try_wait(), Ok(Some(_))) { return; } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let system = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); + let _ = std::process::Command::new(Path::new(&system).join("System32/taskkill.exe")) + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .creation_flags(0x0800_0000).stdout(Stdio::null()).stderr(Stdio::null()).status(); + } + #[cfg(unix)] + unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL); } + let _ = child.kill(); +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkOutcome { /// Combined stdout + stderr from `git`, trimmed. Surfaced to the UI so @@ -77,6 +100,42 @@ pub struct CloneOutcome { pub output: String, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CloneFilter { + BlobNone, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct CloneOptions { + pub branch: Option, + pub depth: Option, + pub single_branch: bool, + pub filter: Option, + pub recurse_submodules: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloneScope { + pub shallow: bool, + pub remotes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloneRemote { + pub name: String, + pub filter: Option, + pub fetch_refspecs: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum HistoryExpansion { + Deepen { commits: u32 }, + Unshallow, +} + /// One progress update parsed from `git`'s stderr while a network op runs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Progress { @@ -116,6 +175,42 @@ pub enum PushMode { } impl Repo { + /// On-demand inspection, including repositories cloned outside Strand. + pub fn clone_scope(&self) -> Result { + let repo = self.git2()?; + let config = repo.config()?; + let mut remotes = Vec::new(); + for name in repo.remotes()?.iter().flatten() { + let remote = repo.find_remote(name)?; + remotes.push(CloneRemote { + name: name.to_owned(), + filter: config.get_string(&format!("remote.{name}.partialclonefilter")).ok(), + fetch_refspecs: remote.fetch_refspecs()?.iter().flatten().map(str::to_owned).collect(), + }); + } + Ok(CloneScope { shallow: repo.is_shallow(), remotes }) + } + + /// Fetch more ancestry without changing HEAD, the index or local edits. + pub fn expand_history( + &self, + remote: &str, + expansion: HistoryExpansion, + on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result { + self.ensure_remote(remote)?; + if !self.git2()?.is_shallow() { + return Err(Error::Other("This repository already has complete history.".into())); + } + let option = match expansion { + HistoryExpansion::Deepen { commits: 0 } => return Err(Error::Other("Depth must be greater than zero.".into())), + HistoryExpansion::Deepen { commits } => format!("--deepen={commits}"), + HistoryExpansion::Unshallow => "--unshallow".into(), + }; + run_git_streaming(&self.path, &["fetch", "--progress", &option, "--", remote], on_progress, cancel) + } + /// Fetch provider-reported branch tips for a read-only comparison without /// updating FETCH_HEAD or any local/remote-tracking ref. Hosted PR views /// use this when a provider exposes commit IDs but not a unified patch. @@ -501,6 +596,16 @@ pub fn clone( dest: &str, on_progress: impl FnMut(Progress), cancel: Option<&CancelHandle>, +) -> Result { + clone_with_options(url, dest, &CloneOptions::default(), on_progress, cancel) +} + +pub fn clone_with_options( + url: &str, + dest: &str, + options: &CloneOptions, + on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, ) -> Result { // The URL is pasted by the user. Make sure git can't read it as an option // (`--upload-pack=…`, `-c …`) or as a command-executing transport @@ -513,7 +618,8 @@ pub fn clone( // Run from the destination's parent so a relative `dest` still lands in // the right place; an absolute `dest` ignores the cwd anyway. let cwd = dest_path.parent().filter(|p| !p.as_os_str().is_empty()); - let args = ["clone", "--progress", "--", url, dest]; + let owned_args = clone_args(url, dest, options)?; + let args = owned_args.iter().map(String::as_str).collect::>(); let outcome = match cwd { Some(parent) => run_git_streaming(parent, &args, on_progress, cancel), None => run_git_streaming(Path::new("."), &args, on_progress, cancel), @@ -524,6 +630,31 @@ pub fn clone( }) } +fn clone_args(url: &str, dest: &str, options: &CloneOptions) -> Result> { + validate_remote_arg(url, "clone URL")?; + let mut args = vec!["clone".into(), "--progress".into()]; + if let Some(branch) = &options.branch { + validate_branch_ref(branch, "clone branch")?; + args.push(format!("--branch={branch}")); + } + if let Some(depth) = options.depth { + if depth == 0 { + return Err(Error::Other("Depth must be greater than zero.".into())); + } + args.push(format!("--depth={depth}")); + } + // Git implies single-branch for --depth. Make the independent UI choice explicit. + args.push(if options.single_branch { "--single-branch" } else { "--no-single-branch" }.into()); + if let Some(CloneFilter::BlobNone) = options.filter { + args.push("--filter=blob:none".into()); + } + if options.recurse_submodules { + args.push("--recurse-submodules".into()); + } + args.extend(["--".into(), url.into(), dest.into()]); + Ok(args) +} + /// Reject a user-supplied remote/URL that git would mis-read as an option or /// a command-executing transport. Paired with an explicit `--` separator at /// the call site, this closes the "paste a malicious clone URL" vector. @@ -590,16 +721,33 @@ pub(crate) fn run_git_streaming( pub(crate) fn run_git_streaming_transcript( cwd: &Path, args: &[&str], + on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, +) -> Result { + run_git_input_transcript(cwd, args, None, on_progress, cancel) +} + +pub(crate) fn run_git_input_transcript( + cwd: &Path, + args: &[&str], + input: Option>, mut on_progress: impl FnMut(Progress), cancel: Option<&CancelHandle>, ) -> Result { - let mut child = crate::git_command() - .current_dir(cwd) + if cancel.is_some_and(CancelHandle::is_cancelled) { return Err(Error::Cancelled); } + let mut command = crate::git_command(); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.current_dir(cwd) .env("GIT_TERMINAL_PROMPT", "0") // Neutralize repo-local config that would run code as a side effect. .args(crate::GIT_SAFE_CONFIG) // Force progress reporting even though stderr isn't a TTY. .args(args) + .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -607,10 +755,17 @@ pub(crate) fn run_git_streaming_transcript( // Drain stdout on a separate thread so a large stdout can't deadlock us // while we're blocked reading stderr (and vice-versa). + let input_handle = input.zip(child.stdin.take()).map(|(input, mut stdin)| { + std::thread::spawn(move || stdin.write_all(&input)) + }); let stdout_handle = child.stdout.take().map(|mut out| { std::thread::spawn(move || { let mut s = String::new(); - let _ = out.read_to_string(&mut s); + let mut buffer = [0u8; 8192]; + while let Ok(n) = out.read(&mut buffer) { + if n == 0 { break; } + append_output(&mut s, &String::from_utf8_lossy(&buffer[..n])); + } s }) }); @@ -623,7 +778,7 @@ pub(crate) fn run_git_streaming_transcript( { let mut inner = handle.0.lock().expect("cancel handle lock"); if inner.cancelled { - let _ = child.kill(); + kill_git_tree(&mut child); let _ = child.wait(); return Err(Error::Cancelled); } @@ -631,21 +786,24 @@ pub(crate) fn run_git_streaming_transcript( } let mut collected = String::new(); + let mut last_progress = std::time::Instant::now() - std::time::Duration::from_secs(1); if let Some(stderr) = stderr { // `git` delimits progress updates with '\r' and ends phases with // '\n', so we split on either. BufRead::lines would coalesce all the // '\r' updates into one line — we want each fragment. for_each_fragment(stderr, |frag| { - collected.push_str(frag); - collected.push('\n'); + append_output(&mut collected, frag); + append_output(&mut collected, "\n"); let p = parse_progress(frag); - if !p.raw.is_empty() { + if !p.raw.is_empty() && (p.percent == Some(100) || last_progress.elapsed().as_millis() >= 100) { on_progress(p); + last_progress = std::time::Instant::now(); } }); } let stdout_str = stdout_handle.and_then(|h| h.join().ok()).unwrap_or_default(); + if let Some(writer) = input_handle { let _ = writer.join(); } // Stderr hit EOF, so the process is done (or killed) — take the child // back out and reap it. After this point a late `cancel()` is a no-op. let status = { @@ -667,6 +825,43 @@ pub(crate) fn run_git_streaming_transcript( }) } +/// Retain a bounded tail, including an explicit marker when output is partial. +fn append_output(output: &mut String, text: &str) { + const LIMIT: usize = 65_536; + const MARKER: &str = "[Earlier output omitted; showing bounded tail]\n"; + output.push_str(text); + if output.len() > LIMIT { + let mut start = output.len() - (LIMIT - MARKER.len()); + while !output.is_char_boundary(start) { start += 1; } + output.drain(..start); + output.insert_str(0, MARKER); + } +} + +#[cfg(test)] +mod bounded_process_tests { + use super::*; + + #[test] + fn output_tail_stays_bounded_and_marks_partial_unicode_output() { + let mut output = String::new(); + for _ in 0..100 { append_output(&mut output, &"é".repeat(8192)); } + assert!(output.len() <= 65_536); + assert!(output.starts_with("[Earlier output omitted")); + } + + #[test] + fn cancellation_kills_helpers_holding_progress_pipes() { + let cancel = CancelHandle::new(); + let mut cancelled_at = None; + let result = run_git_streaming(std::env::temp_dir().as_path(), + &["-c", "alias.strand-cancel-test=!echo strand-ready >&2; sleep 60", "strand-cancel-test"], + |p| { if p.raw.contains("strand-ready") { cancelled_at = Some(std::time::Instant::now()); cancel.cancel(); } }, Some(&cancel)); + assert!(matches!(result, Err(Error::Cancelled))); + assert!(cancelled_at.unwrap().elapsed().as_secs() < 15, "descendants kept pipes open after cancellation"); + } +} + /// Pull the meaningful failure out of a git transcript. git streams progress to /// stderr too, so the full combined output is mostly "Resolving deltas: NN%" /// noise with the actual `fatal:` / `error:` line buried at the very end — @@ -708,6 +903,10 @@ fn for_each_fragment(reader: impl Read, mut sink: impl FnMut(&str)) { } } else { buf.push(b); + if buf.len() == 8192 { + sink(&String::from_utf8_lossy(&buf)); + buf.clear(); + } } } Err(_) => break, diff --git a/crates/strand-core/src/repo.rs b/crates/strand-core/src/repo.rs index 19085402..9e0ea399 100644 --- a/crates/strand-core/src/repo.rs +++ b/crates/strand-core/src/repo.rs @@ -111,14 +111,16 @@ impl Repo { /// Which multi-step history op (if any) is paused mid-flight, detected from /// the on-disk markers git leaves in `.git/`. Returns one of `"rebase"`, - /// `"cherry-pick"`, `"revert"`, `"merge"`, or `None`. Order matters: a + /// `"cherry-pick"`, `"revert"`, `"merge"`, `"mailbox"`, or `None`. Order matters: a /// rebase can leave a `MERGE_HEAD` while resolving, so rebase is checked /// first. Used by [`meta`](Repo::meta) (UI banner) and /// [`abort_operation`](crate::repo::Repo::abort_operation). pub(crate) fn operation_in_progress(&self) -> Option { let git_dir = self.gix.git_dir(); let has = |name: &str| git_dir.join(name).exists(); - if has("rebase-merge") || has("rebase-apply") { + if has("rebase-apply/applying") { + Some("mailbox".into()) + } else if has("rebase-merge") || has("rebase-apply") { Some("rebase".into()) } else if has("CHERRY_PICK_HEAD") { Some("cherry-pick".into()) @@ -126,6 +128,8 @@ impl Repo { Some("revert".into()) } else if has("MERGE_HEAD") { Some("merge".into()) + } else if has("BISECT_START") { + Some("bisect".into()) } else { None } @@ -151,6 +155,9 @@ impl Repo { // check-then-set can't race. if self.git2.get().is_none() { let opened = git2::Repository::open(&self.path)?; + if self.sparse_enabled() && opened.index().is_err() { + self.sparse_read_index(&opened)?; + } let _ = self.git2.set(opened); } Ok(self.git2.get().expect("git2 handle set above")) @@ -163,6 +170,27 @@ impl Repo { Ok(git2::Repository::open(&self.path)?) } + pub(crate) fn is_partial_clone(&self) -> bool { + self.gix.config_snapshot().sections_by_name("remote").is_some_and(|mut sections| { + sections.any(|section| section.value("promisor").is_some_and(|value| value.eq_ignore_ascii_case(b"true"))) + }) + } + + /// libgit2 does not fetch promised objects. Ask Git for this exact object + /// only after a missing-object read in a partial clone, then retry locally. + pub(crate) fn find_blob(&self, oid: git2::Oid) -> Result> { + let repo = self.git2()?; + match repo.find_blob(oid) { + Ok(blob) => Ok(blob), + Err(error) if error.code() == git2::ErrorCode::NotFound && self.is_partial_clone() => { + crate::network::run_git_streaming(&self.path, &["cat-file", "-e", &oid.to_string()], |_| {}, None)?; + repo.odb()?.refresh()?; + Ok(repo.find_blob(oid)?) + } + Err(error) => Err(error.into()), + } + } + /// Resolve `rel_path` against the working directory, rejecting absolute /// paths, `..` traversal, and in-tree symlinks that escape the working /// tree. Mirrors the guard in [`conflict`](crate::conflict); used by the diff --git a/crates/strand-core/src/reset.rs b/crates/strand-core/src/reset.rs index 3a56c940..dcb5f413 100644 --- a/crates/strand-core/src/reset.rs +++ b/crates/strand-core/src/reset.rs @@ -64,27 +64,36 @@ impl Repo { // push+apply round-trip that can fail on Windows file locks). let mut snapshot_oid = None; if matches!(mode, ResetMode::Hard) { - let dirty = repo + let dirty = if self.sparse_enabled() { + self.status()?.iter().any(|entry| entry.kind != crate::status::StatusKind::Untracked) + } else { repo .statuses(Some(&mut crate::status::status_options()))? .iter() .any(|e| { !(e.status() & !(git2::Status::WT_NEW | git2::Status::IGNORED)).is_empty() - }); + }) }; if dirty { let msg = format!("Safety: before hard reset to {target_short}"); snapshot_oid = self.stash_snapshot(Some(&msg), false)?.oid; } } - match mode { + if self.sparse_enabled() || self.is_partial_clone() { + let flag = match mode { ResetMode::Soft => "--soft", ResetMode::Mixed => "--mixed", ResetMode::Hard => "--hard" }; + crate::network::run_git_streaming(&self.path, &["reset", flag, &obj.id().to_string(), "--"], |_| {}, None)?; + } else { match mode { ResetMode::Soft => repo.reset(&obj, git2::ResetType::Soft, None)?, ResetMode::Mixed => repo.reset(&obj, git2::ResetType::Mixed, None)?, ResetMode::Hard => { - let mut co = git2::build::CheckoutBuilder::new(); - co.force(); - repo.reset(&obj, git2::ResetType::Hard, Some(&mut co))?; + if self.lfs_checkout_needed(&obj.peel_to_tree()?)? { + self.run_lfs_filtered(&["reset", "--hard", &obj.id().to_string(), "--"])?; + } else { + let mut co = git2::build::CheckoutBuilder::new(); + co.force(); + repo.reset(&obj, git2::ResetType::Hard, Some(&mut co))?; + } } - } + } } Ok(ResetOutcome { target_short, diff --git a/crates/strand-core/src/signing.rs b/crates/strand-core/src/signing.rs new file mode 100644 index 00000000..fe706fdb --- /dev/null +++ b/crates/strand-core/src/signing.rs @@ -0,0 +1,270 @@ +//! Operation-level signing choices. Inherit leaves Git configuration intact. +use serde::{Deserialize, Serialize}; +use crate::{Error, Repo, Result}; +use crate::gitconfig::{ConfigValues, config_values}; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningMode { + #[default] + Inherit, + Sign, + Unsigned, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningScope { Local, Worktree } + +#[derive(Debug, Serialize)] +pub struct SigningSettings { + pub effective: ConfigValues, + pub local: ConfigValues, + pub worktree: ConfigValues, + pub worktree_enabled: bool, + pub commit_sign: bool, + pub tag_sign: bool, + pub tag_force_annotated: bool, +} + +const SETTINGS: &str = "^(commit\\.gpgsign|tag\\.(gpgsign|forcesignannotated)|user\\.signingkey|gpg\\.(format|ssh\\.allowedsignersfile))$"; + +pub(crate) fn git_bool(repo: &Repo, key: &str) -> Result { + let out = crate::git_output::capture(crate::git_command().current_dir(&repo.path) + .args(crate::GIT_SAFE_CONFIG).args(["config", "--type=bool", "--get", key]))?; + if out.status.code() == Some(1) { return Ok(false); } + if !out.status.success() { + return Err(Error::Other(String::from_utf8_lossy(&out.stderr).trim().to_owned())); + } + Ok(String::from_utf8_lossy(&out.stdout).trim() == "true") +} + +impl Repo { + /// On-demand settings only: no signing/config subprocesses in snapshots. + pub fn signing_settings(&self) -> Result { + let worktree_enabled = git_bool(self, "extensions.worktreeConfig")?; + let effective = config_values(self, None, SETTINGS)?; + let local = config_values(self, Some("--local"), SETTINGS)?; + // Read the direct worktree file, without following any includes. Do + // not enable worktreeConfig implicitly (that may require migration). + let worktree = if worktree_enabled { + config_values(self, Some("--worktree"), SETTINGS)? + } else { ConfigValues::new() }; + Ok(SigningSettings { + effective, local, worktree, worktree_enabled, + commit_sign: git_bool(self, "commit.gpgsign")?, + tag_sign: git_bool(self, "tag.gpgsign")?, + tag_force_annotated: git_bool(self, "tag.forceSignAnnotated")?, + }) + } + + pub fn set_signing_config(&self, scope: SigningScope, key: &str, value: Option<&str>) -> Result<()> { + match key { + "commit.gpgsign" | "tag.gpgsign" | "tag.forcesignannotated" => { + if value.is_some_and(|v| v != "true" && v != "false") { + return Err(Error::Other("Signing state must be true, false, or inherited".into())); + } + } + "gpg.format" => { + if value.is_some_and(|v| !["openpgp", "ssh", "x509"].contains(&v)) { + return Err(Error::Other("Select OpenPGP, SSH, or X.509 signing".into())); + } + } + "user.signingkey" | "gpg.ssh.allowedsignersfile" => {}, + _ => return Err(Error::Other("Unknown signing setting".into())), + } + if key == "user.signingkey" && value.is_some_and(|v| v.contains("-----BEGIN ") && v.contains("PRIVATE KEY-----")) { + return Err(Error::Other("Enter a key ID, public key, or path; private key material is not stored by Strand".into())); + } + let arg = match scope { + SigningScope::Local => "--local", + SigningScope::Worktree => { + if !git_bool(self, "extensions.worktreeConfig")? { + return Err(Error::Other("Enable extensions.worktreeConfig with Git before writing worktree settings".into())); + } + "--worktree" + } + }; + self.set_scoped_config(arg, key, value) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::{path::{Path, PathBuf}, process::Command}; + use crate::tag::TagVerificationStatus; + use crate::commit_metadata::CommitSignatureStatus; + + fn fixture(kind: &str) -> (Repo, PathBuf) { + let dir = std::env::temp_dir().join(format!("strand-signing-{kind}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let g2 = git2::Repository::init(&dir).unwrap(); + let mut config = g2.config().unwrap(); + for (key, value) in [("user.name", "Signer"), ("user.email", "signer@example.com"), + ("commit.gpgsign", "false"), ("tag.gpgsign", "false"), ("tag.forcesignannotated", "false"), ("core.hooksPath", "hooks")] { + config.set_str(key, value).unwrap(); + } + std::fs::write(dir.join("file.txt"), "signed content\n").unwrap(); + let mut index = g2.index().unwrap(); index.add_path(Path::new("file.txt")).unwrap(); index.write().unwrap(); + (Repo::discover(&dir).unwrap(), dir) + } + fn command(program: &Path, args: &[&str]) -> String { + let out = Command::new(program).args(args).output().unwrap(); + assert!(out.status.success(), "{:?}: {}", program, String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).into_owned() + } + fn executable(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + fn sign_suite(repo: &Repo, dir: &Path) { + std::fs::create_dir_all(dir.join("hooks")).unwrap(); + executable(&dir.join("hooks/commit-msg"), "#!/bin/sh\necho signed-hook-rewrite >> \"$1\"\necho signed-hook-output >&2\n"); + let initial = repo.commit_with_signing("signed subject", None, false, SigningMode::Sign).unwrap(); + assert!(initial.output.contains("signed-hook-output")); + assert!(repo.git2().unwrap().head().unwrap().peel_to_commit().unwrap().message().unwrap().contains("signed-hook-rewrite")); + assert_eq!(repo.commit_signature(&initial.oid).unwrap().status, CommitSignatureStatus::Verified); + repo.set_signing_config(SigningScope::Local, "commit.gpgsign", Some("true")).unwrap(); + let amended = repo.commit("signed amend", None, true).unwrap(); + assert_ne!(initial.oid, amended.oid); + assert_eq!(repo.commit_signature(&amended.oid).unwrap().status, CommitSignatureStatus::Verified); + let unsigned = repo.commit_with_signing("unsigned amend", None, true, SigningMode::Unsigned).unwrap(); + assert_eq!(repo.commit_signature(&unsigned.oid).unwrap().status, CommitSignatureStatus::Unsigned); + assert!(repo.signing_settings().unwrap().commit_sign, "operation override did not change config"); + repo.create_tag_with_signing("signed-tag", None, Some("signed annotation"), false, SigningMode::Sign).unwrap(); + assert!(matches!(repo.verify_tag("signed-tag").unwrap().status, TagVerificationStatus::Verified)); + { + let g2 = repo.git2().unwrap(); + let odb = g2.odb().unwrap(); + let tag_oid = g2.refname_to_id("refs/tags/signed-tag").unwrap(); + let object = odb.read(tag_oid).unwrap(); + let changed = String::from_utf8_lossy(object.data()).replacen("signed annotation", "tampered annotation", 1); + let changed_oid = odb.write(git2::ObjectType::Tag, changed.as_bytes()).unwrap(); + g2.reference("refs/tags/tampered-tag", changed_oid, false, "test tampered signature").unwrap(); + assert!(matches!(repo.verify_tag("tampered-tag").unwrap().status, TagVerificationStatus::Failed)); + } + repo.set_signing_config(SigningScope::Local, "tag.gpgsign", Some("true")).unwrap(); + assert!(repo.create_tag("missing-annotation", None, None, false).is_err()); + repo.create_tag("inherited-tag", None, Some("inherited"), false).unwrap(); + assert!(matches!(repo.verify_tag("inherited-tag").unwrap().status, TagVerificationStatus::Verified)); + repo.set_signing_config(SigningScope::Local, "tag.gpgsign", Some("false")).unwrap(); + repo.set_signing_config(SigningScope::Local, "tag.forcesignannotated", Some("true")).unwrap(); + repo.create_tag("forced-annotation", None, Some("force signed"), false).unwrap(); + assert!(matches!(repo.verify_tag("forced-annotation").unwrap().status, TagVerificationStatus::Verified)); + repo.create_tag("inherited-light", None, None, false).unwrap(); + assert!(matches!(repo.verify_tag("inherited-light").unwrap().status, TagVerificationStatus::Unsigned)); + repo.set_signing_config(SigningScope::Local, "tag.gpgsign", Some("true")).unwrap(); + repo.create_tag_with_signing("unsigned-tag", None, Some("unsigned annotation"), false, SigningMode::Unsigned).unwrap(); + repo.create_tag_with_signing("light-tag", None, None, false, SigningMode::Unsigned).unwrap(); + assert!(matches!(repo.verify_tag("unsigned-tag").unwrap().status, TagVerificationStatus::Unsigned)); + assert!(matches!(repo.verify_tag("light-tag").unwrap().status, TagVerificationStatus::Unsigned)); + let linked = dir.join("linked-checkout"); + command(Path::new("git"), &["-C", dir.to_str().unwrap(), "worktree", "add", "-b", "linked", linked.to_str().unwrap()]); + let worktree = Repo::discover(&linked).unwrap(); + let linked_commit = worktree.commit("linked signed amend", None, true).unwrap(); + assert_eq!(worktree.commit_signature(&linked_commit.oid).unwrap().status, CommitSignatureStatus::Verified); + repo.git2().unwrap().config().unwrap().set_bool("extensions.worktreeConfig", true).unwrap(); + worktree.set_signing_config(SigningScope::Worktree, "commit.gpgsign", Some("false")).unwrap(); + assert!(!worktree.signing_settings().unwrap().commit_sign); + assert!(repo.signing_settings().unwrap().commit_sign); + let linked_unsigned = worktree.commit("linked unsigned amend", None, true).unwrap(); + assert_eq!(worktree.commit_signature(&linked_unsigned.oid).unwrap().status, CommitSignatureStatus::Unsigned); + assert_eq!(repo.git2().unwrap().head().unwrap().target().unwrap().to_string(), unsigned.oid); + executable(&dir.join("hooks/pre-commit"), "#!/bin/sh\necho signed-hook-rejected >&2\nexit 1\n"); + assert!(repo.commit("reject", None, true).unwrap_err().to_string().contains("signed-hook-rejected")); + assert_eq!(repo.git2().unwrap().head().unwrap().target().unwrap().to_string(), unsigned.oid); + std::fs::remove_file(dir.join("hooks/pre-commit")).unwrap(); + repo.set_signing_config(SigningScope::Local, "user.signingkey", Some("strand-no-such-key")).unwrap(); + assert!(repo.commit("bad signer", None, true).is_err()); + assert!(repo.create_tag_with_signing("failed-tag", None, Some("failed"), false, SigningMode::Sign).is_err()); + assert!(repo.git2().unwrap().find_reference("refs/tags/failed-tag").is_err()); + assert_eq!(repo.git2().unwrap().head().unwrap().target().unwrap().to_string(), unsigned.oid); + } + + #[test] + fn signing_settings_distinguish_valueless_and_empty_git_booleans() { + let (repo, dir) = fixture("booleans"); + let config_path = dir.join(".git/config"); + let mut config = std::fs::read_to_string(&config_path).unwrap(); + config.push_str("\n[commit]\ngpgsign\n[tag]\ngpgsign =\nforcesignannotated = yes\n"); + std::fs::write(config_path, config).unwrap(); + let state = repo.signing_settings().unwrap(); + assert!(state.commit_sign); + assert!(!state.tag_sign); + assert!(state.tag_force_annotated); + assert_eq!(state.local["commit.gpgsign"].value, "true"); + assert_eq!(state.local["tag.gpgsign"].value, "false"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn signing_settings_are_scoped_and_do_not_enable_worktree_config_implicitly() { + let (repo, dir) = fixture("scope"); + let (other, other_dir) = fixture("other-scope"); + assert!(repo.set_signing_config(SigningScope::Worktree, "commit.gpgsign", Some("true")).is_err()); + repo.set_signing_config(SigningScope::Local, "commit.gpgsign", Some("true")).unwrap(); + assert!(repo.signing_settings().unwrap().commit_sign); + assert!(!other.signing_settings().unwrap().commit_sign); + let g2 = repo.git2().unwrap(); + g2.config().unwrap().set_bool("extensions.worktreeConfig", true).unwrap(); + repo.set_signing_config(SigningScope::Worktree, "commit.gpgsign", Some("false")).unwrap(); + let state = repo.signing_settings().unwrap(); + assert_eq!(state.local["commit.gpgsign"].value, "true"); + assert_eq!(state.worktree["commit.gpgsign"].value, "false"); + assert_eq!(state.effective["commit.gpgsign"].scope, "worktree"); + assert!(!state.commit_sign); + repo.set_signing_config(SigningScope::Worktree, "commit.gpgsign", None).unwrap(); + assert!(repo.signing_settings().unwrap().commit_sign); + assert!(repo.set_signing_config(SigningScope::Local, "core.hooksPath", Some("unrelated")).is_err()); + assert!(repo.set_signing_config(SigningScope::Local, "user.signingkey", Some("-----BEGIN OPENSSH PRIVATE KEY-----")).is_err()); + let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_dir_all(other_dir); + } + + #[test] + #[ignore = "integration fixture requires ssh-keygen"] + fn ssh_commit_amend_hooks_tags_and_failures() { + let (repo, dir) = fixture("ssh"); + let key = dir.join("signing-key"); + command(Path::new("ssh-keygen"), &["-q", "-t", "ed25519", "-N", "", "-f", key.to_str().unwrap()]); + let allowed = dir.join("allowed-signers"); + let public = std::fs::read_to_string(dir.join("signing-key.pub")).unwrap(); + std::fs::write(&allowed, format!("signer@example.com {public}")).unwrap(); + repo.set_signing_config(SigningScope::Local, "gpg.format", Some("ssh")).unwrap(); + repo.set_signing_config(SigningScope::Local, "user.signingkey", key.to_str()).unwrap(); + repo.set_signing_config(SigningScope::Local, "gpg.ssh.allowedsignersfile", allowed.to_str()).unwrap(); + sign_suite(&repo, &dir); + // Missing allowed signers is visibly different from unsigned. + repo.set_signing_config(SigningScope::Local, "gpg.ssh.allowedsignersfile", Some("missing-signers-file")).unwrap(); + assert!(matches!(repo.verify_tag("signed-tag").unwrap().status, TagVerificationStatus::Failed)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + #[ignore = "integration fixture requires GPG (Git for Windows includes it)"] + fn gpg_commit_amend_hooks_tags_and_failures() { + let (repo, dir) = fixture("gpg"); + let gpg = if cfg!(windows) { PathBuf::from("C:/Program Files/Git/usr/bin/gpg.exe") } else { PathBuf::from("/usr/bin/gpg") }; + let home = dir.join("gnupg"); std::fs::create_dir_all(&home).unwrap(); + let native_home = home.to_string_lossy().replace('\\', "/"); + // Git for Windows ships MSYS GPG: its Unix socket path cannot contain + // a drive colon. Native GPG elsewhere takes the original absolute path. + let home_arg = if cfg!(windows) { format!("/{}{}", native_home[..1].to_lowercase(), &native_home[2..]) } else { native_home }; + command(&gpg, &["--homedir", &home_arg, "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", "Signer ", "ed25519", "sign", "0"]); + let quote = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\\', "/").replace('\'', "'\"'\"'")); + let wrapper = dir.join("fixture-gpg"); + executable(&wrapper, &format!("#!/bin/sh\nexec {} --homedir {} \"$@\"\n", quote(&gpg), quote(Path::new(&home_arg)))); + repo.git2().unwrap().config().unwrap().set_str("gpg.program", wrapper.to_str().unwrap()).unwrap(); + repo.set_signing_config(SigningScope::Local, "gpg.format", Some("openpgp")).unwrap(); + repo.set_signing_config(SigningScope::Local, "user.signingkey", Some("signer@example.com")).unwrap(); + sign_suite(&repo, &dir); + let gpgconf = gpg.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" }); + command(&gpgconf, &["--homedir", &home_arg, "--kill", "gpg-agent"]); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/strand-core/src/snapshot.rs b/crates/strand-core/src/snapshot.rs index b82280fe..d22ae4d8 100644 --- a/crates/strand-core/src/snapshot.rs +++ b/crates/strand-core/src/snapshot.rs @@ -35,7 +35,7 @@ impl Repo { pub fn snapshot(&self) -> Result { let repo = self.git2()?; let statuses = repo.statuses(Some(&mut crate::status::status_options()))?; - let status = crate::status::from_statuses(&statuses); + let status = if self.sparse_enabled() { self.status()? } else { crate::status::from_statuses(&statuses) }; let work_tree = crate::tree::from_index_and_statuses(repo, &statuses)?; drop(statuses); diff --git a/crates/strand-core/src/sparse.rs b/crates/strand-core/src/sparse.rs new file mode 100644 index 00000000..14ffb47a --- /dev/null +++ b/crates/strand-core/src/sparse.rs @@ -0,0 +1,159 @@ +//! Cone checkout management and an in-memory read bridge for libgit2 1.8, +//! which cannot read Git's mandatory sparse-directory index extension. +use std::{collections::BTreeSet, io::Write, process::Stdio}; +use serde::{Deserialize, Serialize}; +use crate::{Error, Repo, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SparseCheckout { + pub enabled: bool, + pub cone: bool, + pub sparse_index: bool, + pub directories: Vec, + pub available: Vec, + pub patterns: String, +} + +impl Repo { + pub(crate) fn sparse_enabled(&self) -> bool { + self.gix.config_snapshot().boolean("core.sparseCheckout").unwrap_or(false) + } + + pub fn sparse_checkout(&self) -> Result { + let enabled = self.sparse_enabled(); + let config = self.gix.config_snapshot(); + let cone = config.boolean("core.sparseCheckoutCone").unwrap_or(false); + let sparse_index = config.boolean("index.sparse").unwrap_or(false); + let patterns = if enabled { + std::fs::read_to_string(self.git_dir().join("info/sparse-checkout"))? + } else { String::new() }; + let directories = if enabled && cone { + // Read the literal cone rules rather than Git's quoted display output. + patterns.lines().filter_map(|line| { + if line.starts_with('/') && line.ends_with('/') && line != "/*" { + Some(unescape_cone(&line[1..line.len() - 1])) + } else { None } + }).filter(|dir| !patterns.lines().any(|line| line == format!("!/{dir}/*/", dir = escape_cone(dir)))).collect() + } else { Vec::new() }; + let available = self.sparse_git(&["ls-tree", "-d", "-r", "--name-only", "-z", "HEAD"], None)?; + Ok(SparseCheckout { enabled, cone, sparse_index, directories, available: nul_paths(&available)?, patterns }) + } + + pub fn set_sparse_checkout(&self, directories: &[String], sparse_index: bool) -> Result { + let state = self.sparse_checkout()?; + if state.enabled && !state.cone { + return Err(Error::Other("This checkout uses non-cone patterns. Disable it before selecting cone directories.".into())); + } + let available: BTreeSet<_> = state.available.iter().chain(state.directories.iter()).collect(); + for directory in directories { + if directory.is_empty() || directory.contains(['\\', '\n', '\r', '\0']) + || directory.split('/').any(|part| part.is_empty() || part == "." || part == ".." || part.eq_ignore_ascii_case(".git")) + || !available.contains(directory) + { + return Err(Error::Other(format!("Not a tracked repository directory: {directory}"))); + } + } + self.ensure_sparse_change_clean(Some(directories))?; + // --stdin avoids Windows argv limits and Git option/pathspec interpretation. + let input = directories.iter().map(|dir| format!("\"{}\"\n", dir.replace('"', "\\\""))).collect::(); + let output = self.sparse_git(&["sparse-checkout", "set", "--cone", if sparse_index { "--sparse-index" } else { "--no-sparse-index" }, "--stdin"], Some(input.as_bytes()))?; + Ok(String::from_utf8_lossy(&output).trim().into()) + } + + pub fn disable_sparse_checkout(&self) -> Result { + self.ensure_sparse_change_clean(None)?; + let output = self.sparse_git(&["sparse-checkout", "disable"], None)?; + Ok(String::from_utf8_lossy(&output).trim().into()) + } + + fn ensure_sparse_change_clean(&self, directories: Option<&[String]>) -> Result<()> { + if self.operation_in_progress().is_some() { + return Err(Error::Other("Finish the current Git operation before changing sparse checkout.".into())); + } + let output = self.sparse_git(&["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal"], None)?; + let records = nul_paths(&output)?; + for record in records { + if let Some(path) = record.strip_prefix("!! ") { + let probe = if path.ends_with('/') { format!("{path}__ignored__") } else { path.to_owned() }; + if directories.is_some_and(|dirs| !cone_includes(&probe, dirs)) { + return Err(Error::Other(format!("Ignored files in {path} could be removed by Git. Include that directory or move those files before changing sparse checkout."))); + } + } else { + return Err(Error::Other("Commit or stash local changes and move untracked files before changing sparse checkout. Your files and index have been preserved.".into())); + } + } + // The read-only guard leaves index stat data untouched. Refresh only + // after it passes: otherwise Git can retain a restored clean file as + // "not up to date" when removing its directory from the cone. + self.sparse_git(&["update-index", "--refresh"], None)?; + Ok(()) + } + + pub(crate) fn sparse_git(&self, args: &[&str], input: Option<&[u8]>) -> Result> { + let mut child = crate::git_command().current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0").env("GIT_OPTIONAL_LOCKS", "0") + .args(crate::GIT_SAFE_CONFIG).args(args) + .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + if let Some(input) = input { + // Write concurrently so an early Git error cannot deadlock a large selection. + let mut stdin = child.stdin.take().expect("piped stdin"); + let input = input.to_vec(); + std::thread::spawn(move || { let _ = stdin.write_all(&input); }); + } + let output = child.wait_with_output()?; + if !output.status.success() { + return Err(Error::Other(String::from_utf8_lossy(&output.stderr).trim().into())); + } + let mut bytes = output.stdout; + // Successful sparse changes can still warn about retained files. + if args.first() == Some(&"sparse-checkout") { bytes.extend_from_slice(&output.stderr); } + Ok(bytes) + } + + /// Attach an expanded *memory-only* index for readers. Never rewrite the + /// user's sparse index merely by opening/refreshing a repository. + pub(crate) fn sparse_read_index(&self, repo: &git2::Repository) -> Result<()> { + let output = self.sparse_git(&["ls-files", "--stage", "-t", "-z"], None)?; + let mut index = git2::Index::new()?; + for record in output.split(|byte| *byte == 0).filter(|row| !row.is_empty()) { + let tab = record.iter().position(|byte| *byte == b'\t').ok_or_else(|| Error::Other("Invalid Git index listing".into()))?; + let header = std::str::from_utf8(&record[..tab]).map_err(|e| Error::Other(e.to_string()))?; + let fields: Vec<_> = header.split(' ').collect(); + if fields.len() != 4 { return Err(Error::Other("Invalid Git index entry".into())); } + let mode = u32::from_str_radix(fields[1], 8).map_err(|e| Error::Other(e.to_string()))?; + let stage = fields[3].parse::().map_err(|e| Error::Other(e.to_string()))?; + index.add(&git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), mtime: git2::IndexTime::new(0, 0), + dev: 0, ino: 0, mode, uid: 0, gid: 0, file_size: 0, + id: git2::Oid::from_str(fields[2])?, flags: stage << 12, + flags_extended: if fields[0] == "S" { 1 << 14 } else { 0 }, + path: record[tab + 1..].to_vec(), + })?; + } + repo.set_index(&mut index)?; + Ok(()) + } +} + +pub(crate) fn cone_includes(path: &str, directories: &[String]) -> bool { + !path.contains('/') || directories.iter().any(|dir| { + path == dir || path.starts_with(&format!("{dir}/")) + || path.rsplit_once('/').is_some_and(|(parent, _)| dir.starts_with(&format!("{parent}/"))) + }) +} + +fn nul_paths(bytes: &[u8]) -> Result> { + bytes.split(|byte| *byte == 0).filter(|row| !row.is_empty()) + .map(|row| String::from_utf8(row.to_vec()).map_err(|_| Error::Other("Sparse directory names must be UTF-8.".into()))).collect() +} + +fn unescape_cone(value: &str) -> String { + let mut chars = value.chars(); + let mut result = String::new(); + while let Some(c) = chars.next() { result.push(if c == '\\' { chars.next().unwrap_or(c) } else { c }); } + result +} +fn escape_cone(value: &str) -> String { + value.chars().flat_map(|c| if matches!(c, '*' | '?' | '[' | ']' | '\\') { vec!['\\', c] } else { vec![c] }).collect() +} diff --git a/crates/strand-core/src/stage.rs b/crates/strand-core/src/stage.rs index d1c1b7b7..db5e4f32 100644 --- a/crates/strand-core/src/stage.rs +++ b/crates/strand-core/src/stage.rs @@ -6,6 +6,10 @@ impl Repo { /// Stage `path` — adds new/modified files, records deletions. Mirrors /// `git add ` for one path at a time. pub fn stage_path(&self, path: &str) -> Result<()> { + if self.is_lfs_path(Path::new(path))? { + return self.stage_lfs_paths(&[path.to_owned()]); + } + if self.sparse_enabled() { return self.stage_paths(&[path.into()]); } let repo = self.git2()?; let mut index = repo.index()?; @@ -30,6 +34,17 @@ impl Repo { if paths.is_empty() { return Ok(()); } + for path in paths { + if self.is_lfs_path(Path::new(path))? { + return self.stage_lfs_paths(paths); + } + } + if self.sparse_enabled() { + let mut args = vec!["--literal-pathspecs", "add", "--"]; + args.extend(paths.iter().map(String::as_str)); + self.sparse_git(&args, None)?; + return Ok(()); + } let repo = self.git2()?; let mut index = repo.index()?; let workdir = repo.workdir().map(Path::to_path_buf); @@ -53,6 +68,12 @@ impl Repo { if paths.is_empty() { return Ok(()); } + if self.sparse_enabled() { + let mut args = vec!["--literal-pathspecs", "restore", "--staged", "--"]; + args.extend(paths.iter().map(String::as_str)); + self.sparse_git(&args, None)?; + return Ok(()); + } let repo = self.git2()?; match repo.head().ok().map(|h| h.peel_to_commit()) { None => { @@ -97,6 +118,17 @@ impl Repo { } } if !tracked.is_empty() { + for path in &tracked { + if self.is_lfs_path(Path::new(path))? { + return self.discard_lfs_paths(&tracked); + } + } + if self.sparse_enabled() { + let mut args = vec!["--literal-pathspecs", "checkout-index", "--force", "--"]; + args.extend(tracked); + self.sparse_git(&args, None)?; + return Ok(()); + } let mut opts = git2::build::CheckoutBuilder::new(); // This command opened a fresh repository + index above, so there // is nothing stale to refresh. More importantly, libgit2's refresh @@ -124,6 +156,7 @@ impl Repo { /// without touching the working tree. Equivalent to /// `git restore --staged `. pub fn unstage_path(&self, path: &str) -> Result<()> { + if self.sparse_enabled() { return self.unstage_paths(&[path.into()]); } let repo = self.git2()?; match repo.head().ok().map(|h| h.peel_to_commit()) { // No HEAD yet (unborn branch): just drop the index entry. diff --git a/crates/strand-core/src/status.rs b/crates/strand-core/src/status.rs index dc05a56e..1e5dec01 100644 --- a/crates/strand-core/src/status.rs +++ b/crates/strand-core/src/status.rs @@ -26,6 +26,31 @@ impl Repo { /// Uses `git2` for now because gix's status APIs are still maturing; /// the public type intentionally hides which engine produced it. pub fn status(&self) -> Result> { + if self.sparse_enabled() { + let output = self.sparse_git(&["status", "--porcelain=v1", "-z", "--untracked-files=all"], None)?; + let mut records = output.split(|byte| *byte == 0).filter(|row| !row.is_empty()); + let mut result = Vec::new(); + while let Some(row) = records.next() { + if row.len() < 4 { continue; } + let path = String::from_utf8_lossy(&row[3..]).into_owned(); + let (x, y) = (row[0], row[1]); + if x == b'R' || x == b'C' || y == b'R' || y == b'C' { records.next(); } + if x == b'U' || y == b'U' || (x == b'A' && y == b'A') || (x == b'D' && y == b'D') { + result.push(FileStatus { path, kind: StatusKind::Conflicted, staged: false }); + continue; + } + for (code, staged) in [(x, true), (y, false)] { + let kind = match code { + b'A' | b'C' => StatusKind::Added, b'D' => StatusKind::Deleted, + b'R' => StatusKind::Renamed, b'M' | b'T' => StatusKind::Modified, + b'?' if !staged => StatusKind::Untracked, + _ => continue, + }; + result.push(FileStatus { path: path.clone(), kind, staged }); + } + } + return Ok(result); + } let repo = self.git2()?; let statuses = repo.statuses(Some(&mut status_options()))?; Ok(from_statuses(&statuses)) diff --git a/crates/strand-core/src/submodule.rs b/crates/strand-core/src/submodule.rs index 2c3673eb..9b99ee18 100644 --- a/crates/strand-core/src/submodule.rs +++ b/crates/strand-core/src/submodule.rs @@ -1,4 +1,4 @@ -//! Submodules — list + status (read) and `update --init` (write). +//! Submodules — status, lazy nested inspection, and guarded Git lifecycle actions. //! //! Reads go through `git2` (`Repository::submodules` + `submodule_status`), //! which gives us the recorded vs checked-out OIDs and a status bitset in one @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ error::{Error, Result}, - network::{NetworkOutcome, Progress}, + network::{run_git_streaming, CancelHandle, NetworkOutcome, Progress}, repo::Repo, }; @@ -50,7 +50,270 @@ pub struct Submodule { pub status: SubmoduleState, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "kebab-case")] +pub enum SubmoduleAction { + Add { url: String, path: String }, + Remove { path: String }, + Deinit { path: String }, + Sync { path: String, recursive: bool }, + SetUrl { path: String, url: String }, + Update { path: String, recursive: bool }, + Inspect { path: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmodulePage { + pub modules: Vec, + pub next_offset: Option, +} + impl Repo { + /// Children are requested a level/page at a time. No recursive dirty walk + /// is added to the repository snapshot or the sidebar refresh. + pub fn submodule_children(&self, parent: &str, offset: usize) -> Result { + let nested; + let owner = if parent.is_empty() { + self + } else { + nested = self.open_nested_submodule(parent)?; + &nested + }; + let repo = owner.git2()?; + let mut modules = repo.submodules()?; + modules.sort_by_key(|sm| sm.path().to_path_buf()); + let total = modules.len(); + let mut out = Vec::new(); + for sm in modules.into_iter().skip(offset).take(100) { + let workdir_id = sm.workdir_id().map(|id| id.to_string()); + let head_id = sm.index_id().map(|id| id.to_string()); + let initialized = workdir_id.is_some(); + out.push(Submodule { + name: sm.name().unwrap_or_default().into(), + path: sm.path().to_string_lossy().replace('\\', "/"), + url: sm.url().map(str::to_owned), + status: if !initialized { + SubmoduleState::Uninitialized + } else if head_id != workdir_id { + SubmoduleState::OutOfDate + } else { + SubmoduleState::UpToDate + }, + head_id, + workdir_id, + initialized, + }); + } + Ok(SubmodulePage { + modules: out, + next_offset: (offset.saturating_add(100) < total).then_some(offset.saturating_add(100)), + }) + } + + fn open_nested_submodule(&self, path: &str) -> Result { + validate_module_path(self, path)?; + // Resolve only registered module edges, never arbitrary nested repos. + let mut owner = Repo::discover(&self.path)?; + let mut remaining = path; + for _ in 0..32 { + let next = owner + .git2()? + .submodules()? + .into_iter() + .find_map(|sm| { + let child = sm.path().to_string_lossy().replace('\\', "/"); + (remaining == child || remaining.starts_with(&format!("{child}/"))) + .then_some(child) + }) + .ok_or_else(|| Error::Other(format!("Not a registered submodule: {path}")))?; + let target = owner.path.join(&next); + let opened = Repo::discover(&target)?; + if opened.path.canonicalize()? != target.canonicalize()? { + return Err(Error::Other(format!( + "Initialize {path} before inspecting its children." + ))); + } + if remaining == next { + return Ok(opened); + } + remaining = &remaining[next.len() + 1..]; + owner = opened; + } + Err(Error::Other( + "Nested submodule depth exceeds 32; open the module as a repository to continue." + .into(), + )) + } + + pub fn submodule_action( + &self, + action: SubmoduleAction, + mut progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result { + let (path, changes_modules, needs_clean) = match &action { + SubmoduleAction::Add { path, .. } => (path, true, false), + SubmoduleAction::Remove { path } => (path, true, true), + SubmoduleAction::Deinit { path } => (path, false, true), + SubmoduleAction::SetUrl { path, .. } => (path, true, false), + SubmoduleAction::Update { path, .. } => (path, false, true), + SubmoduleAction::Sync { path, .. } | SubmoduleAction::Inspect { path } => { + (path, false, false) + } + }; + validate_module_path(self, path)?; + let repo = self.git2()?; + let registered = repo + .submodules()? + .into_iter() + .any(|sm| sm.path() == std::path::Path::new(path)); + if !matches!(action, SubmoduleAction::Add { .. }) && !registered { + return Err(Error::Other(format!("Not a registered submodule: {path}"))); + } + let modules_clean = match repo.status_file(std::path::Path::new(".gitmodules")) { + Ok(status) => status.is_empty(), + Err(error) => error.code() == git2::ErrorCode::NotFound, + }; + if changes_modules && !modules_clean { + return Err(Error::Other("Commit or restore .gitmodules before changing submodule registration; its staged and unstaged edits are preserved.".into())); + } + if needs_clean { + self.ensure_submodule_clean( + path, + matches!( + action, + SubmoduleAction::Remove { .. } | SubmoduleAction::Deinit { .. } + ), + &mut progress, + cancel, + )?; + } + if let SubmoduleAction::Inspect { path } = &action { + let child = self.open_nested_submodule(path)?; + return run_git_streaming( + &child.path, + &[ + "status", + "--short", + "--branch", + "--untracked-files=normal", + "--ignore-submodules=none", + ], + progress, + cancel, + ); + } + let mut args = vec!["--literal-pathspecs".to_string()]; + match action { + SubmoduleAction::Add { url, path } => { + crate::network::validate_remote_arg(&url, "submodule URL")?; + if self.path.join(&path).exists() { + return Err(Error::Other( + "Choose a new, empty submodule path; existing directories are preserved." + .into(), + )); + } + args.extend([ + "submodule".into(), + "add".into(), + "--progress".into(), + "--".into(), + url, + path, + ]); + } + SubmoduleAction::Remove { path } => args.extend(["rm".into(), "--".into(), path]), + SubmoduleAction::Deinit { path } => { + args.extend(["submodule".into(), "deinit".into(), "--".into(), path]) + } + SubmoduleAction::SetUrl { path, url } => { + crate::network::validate_remote_arg(&url, "submodule URL")?; + args.extend(["submodule".into(), "set-url".into(), "--".into(), path, url]); + } + SubmoduleAction::Sync { path, recursive } => { + args.extend(["submodule".into(), "sync".into()]); + if recursive { + args.push("--recursive".into()); + } + args.extend(["--".into(), path]); + } + SubmoduleAction::Update { path, recursive } => { + args.extend([ + "submodule".into(), + "update".into(), + "--init".into(), + "--progress".into(), + ]); + if recursive { + args.push("--recursive".into()); + } + args.extend(["--".into(), path]); + } + SubmoduleAction::Inspect { .. } => unreachable!(), + } + run_git_streaming( + &self.path, + &args.iter().map(String::as_str).collect::>(), + progress, + cancel, + ) + } + + fn ensure_submodule_clean( + &self, + path: &str, + require_recorded_commit: bool, + progress: &mut dyn FnMut(Progress), + cancel: Option<&CancelHandle>, + ) -> Result<()> { + let workdir = self.path.join(path); + if !workdir.join(".git").exists() { + if workdir.exists() && std::fs::read_dir(workdir)?.next().is_some() { + return Err(Error::Other(format!("Uninitialized module {path} contains files. Move or preserve them before retrying."))); + } + return Ok(()); + } + let child = self.open_nested_submodule(path)?; + let status = run_git_streaming( + &child.path, + &[ + "status", + "--porcelain", + "--untracked-files=normal", + "--ignore-submodules=none", + if require_recorded_commit { + "--ignored=matching" + } else { + "--ignored=no" + }, + ], + &mut *progress, + cancel, + )?; + if !status.output.is_empty() { + return Err(Error::Other(format!( + "{path} has local or nested changes. Commit or stash them before retrying.\n{}", + status.output + ))); + } + if require_recorded_commit { + let expected = self + .git2()? + .index()? + .get_path(std::path::Path::new(path), 0) + .map(|e| e.id); + if child.git2()?.head()?.target() != expected { + return Err(Error::Other(format!("{path} is checked out at a different commit than the index. Preserve that commit and stage/commit the gitlink before retrying."))); + } + // Git's parent status omits ignored files inside nested modules. + // Before deleting directories, check every initialized child too. + for nested in child.git2()?.submodules()? { + let nested_path = nested.path().to_string_lossy().replace('\\', "/"); + child.ensure_submodule_clean(&nested_path, true, progress, cancel)?; + } + } + Ok(()) + } /// List every submodule with its status. Best-effort per submodule — a /// status lookup that fails (e.g. a malformed `.gitmodules` entry) falls /// back to `Uninitialized` rather than failing the whole listing. @@ -93,16 +356,29 @@ impl Repo { paths: &[String], init: bool, recursive: bool, - on_progress: impl FnMut(Progress), + mut on_progress: impl FnMut(Progress), + cancel: Option<&CancelHandle>, ) -> Result { - for p in paths { - if p.starts_with('-') { - return Err(Error::Other(format!( - "submodule path may not start with '-': {p}" - ))); + let modules = self.git2()?.submodules()?; + let selected: Vec = if paths.is_empty() { + modules + .iter() + .map(|sm| sm.path().to_string_lossy().replace('\\', "/")) + .collect() + } else { + paths.to_vec() + }; + for p in &selected { + validate_module_path(self, p)?; + if !modules + .iter() + .any(|sm| sm.path() == std::path::Path::new(p)) + { + return Err(Error::Other(format!("Not a registered submodule: {p}"))); } + self.ensure_submodule_clean(p, false, &mut on_progress, cancel)?; } - let mut args: Vec<&str> = vec!["submodule", "update", "--progress"]; + let mut args: Vec<&str> = vec!["--literal-pathspecs", "submodule", "update", "--progress"]; if init { args.push("--init"); } @@ -117,10 +393,36 @@ impl Repo { args.push(p.as_str()); } } - crate::network::run_git_streaming(&self.path, &args, on_progress, None) + crate::network::run_git_streaming(&self.path, &args, on_progress, cancel) } } +fn validate_module_path(repo: &Repo, path: &str) -> Result<()> { + if path.is_empty() + || path.starts_with('-') + || path.contains(['\0', '\n', '\r', '\\', ':']) + || path.split('/').any(|part| { + part.is_empty() || part == "." || part == ".." || part.eq_ignore_ascii_case(".git") + }) + { + return Err(Error::Other("Use a repository-relative submodule path with forward slashes and no '.' or '..' components.".into())); + } + let root = repo.path.canonicalize()?; + let mut target = repo.path.clone(); + for part in path.split('/') { + target.push(part); + if let Ok(meta) = std::fs::symlink_metadata(&target) { + if meta.file_type().is_symlink() || !target.canonicalize()?.starts_with(&root) { + return Err(Error::Other( + "Submodule path must stay inside the repository without symlink traversal." + .into(), + )); + } + } + } + Ok(()) +} + /// Reduce git2's `SubmoduleStatus` to a single [`SubmoduleState`] plus an /// `initialized` flag. Order: uninitialized first (no point reporting "modified" /// on a submodule with no working tree), then local working-tree changes, then a @@ -152,7 +454,11 @@ mod tests { use std::process::Command; fn git(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git").current_dir(dir).args(args).output().unwrap(); + let out = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .unwrap(); assert!( out.status.success(), "git {:?} failed: {}", @@ -211,9 +517,269 @@ mod tests { assert_eq!(m.path, "sub"); assert!(m.initialized, "freshly added submodule has a working tree"); assert!(m.head_id.is_some()); - assert_eq!(m.head_id, m.workdir_id, "checked out at the recorded commit"); + assert_eq!( + m.head_id, m.workdir_id, + "checked out at the recorded commit" + ); assert_eq!(m.status, SubmoduleState::UpToDate); + // Real dirty/untracked state is checked lazily at the mutation boundary. + std::fs::write(sup.join("sub/local.txt"), "keep me").unwrap(); + for action in [ + SubmoduleAction::Deinit { path: "sub".into() }, + SubmoduleAction::Remove { path: "sub".into() }, + SubmoduleAction::Update { + path: "sub".into(), + recursive: true, + }, + ] { + assert!(repo + .submodule_action(action, |_| {}, None) + .unwrap_err() + .to_string() + .contains("local or nested changes")); + } + assert_eq!( + std::fs::read_to_string(sup.join("sub/local.txt")).unwrap(), + "keep me" + ); + std::fs::remove_file(sup.join("sub/local.txt")).unwrap(); + let index_before = git(&sup, &["write-tree"]); + let new_url = format!("{sub_url}-new"); + repo.submodule_action( + SubmoduleAction::SetUrl { + path: "sub".into(), + url: new_url.clone(), + }, + |_| {}, + None, + ) + .unwrap(); + assert_eq!( + git(&sup, &["config", "-f", ".gitmodules", "submodule.sub.url"]), + new_url + ); + assert_eq!(git(&sup, &["config", "submodule.sub.url"]), new_url); + assert_eq!( + git(&sup, &["write-tree"]), + index_before, + "URL edit does not silently stage .gitmodules" + ); + assert!( + repo.submodule_action(SubmoduleAction::Remove { path: "sub".into() }, |_| {}, None) + .is_err(), + "pending .gitmodules edits are protected" + ); + git(&sup, &["checkout", "--", ".gitmodules"]); + repo.submodule_action( + SubmoduleAction::Sync { + path: "sub".into(), + recursive: true, + }, + |_| {}, + None, + ) + .unwrap(); + assert_eq!(git(&sup, &["config", "submodule.sub.url"]), sub_url); + + // Add a nested module using the fixture-only local transport override. + let nested_path = sup.join("sub"); + git( + &nested_path, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + &sub_url, + "nested", + ], + ); + assert_eq!( + repo.submodule_children("sub", 0).unwrap().modules[0].path, + "nested" + ); + assert!(repo + .submodule_children("sub/nested", 0) + .unwrap() + .modules + .is_empty()); + assert!(repo.submodule_children("../sub", 0).is_err()); + assert!(repo.submodule_children("sub/a.txt", 0).is_err()); + assert!(repo.submodule_children("", 100).unwrap().modules.is_empty()); + assert!(repo + .submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .is_err()); + git( + &nested_path, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "nested", + ], + ); + assert!(repo + .submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .unwrap_err() + .to_string() + .contains("different commit")); + repo.stage_path("sub").unwrap(); + repo.commit("record nested", None, false).unwrap(); + std::fs::write(sup.join("sub/nested/untracked.txt"), "nested data").unwrap(); + assert!(repo + .submodule_action(SubmoduleAction::Remove { path: "sub".into() }, |_| {}, None) + .unwrap_err() + .to_string() + .contains("local or nested changes")); + std::fs::remove_file(sup.join("sub/nested/untracked.txt")).unwrap(); + let ignored_rules = base.join("ignored-rules"); + std::fs::write(&ignored_rules, "*.secret\n").unwrap(); + for module in [sup.join("sub"), sup.join("sub/nested")] { + git( + &module, + &[ + "config", + "core.excludesFile", + ignored_rules.to_str().unwrap(), + ], + ); + std::fs::write(module.join("local.secret"), "ignored local data").unwrap(); + assert!(repo + .submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .unwrap_err() + .to_string() + .contains("local or nested changes")); + assert_eq!( + std::fs::read_to_string(module.join("local.secret")).unwrap(), + "ignored local data" + ); + std::fs::remove_file(module.join("local.secret")).unwrap(); + } + let before_deinit = git(&sup, &["write-tree"]); + repo.submodule_action(SubmoduleAction::Deinit { path: "sub".into() }, |_| {}, None) + .unwrap(); + assert_eq!(git(&sup, &["write-tree"]), before_deinit); + assert!(sup.join(".gitmodules").exists()); + assert!(!sup.join("sub/a.txt").exists()); + // Local module data survives deinit, so reinitialization needs no fetch. + repo.submodule_update(&["sub".into()], true, false, |_| {}, None) + .unwrap(); + assert!(sup.join("sub/a.txt").exists()); + repo.submodule_action(SubmoduleAction::Remove { path: "sub".into() }, |_| {}, None) + .unwrap(); + assert!(git(&sup, &["ls-files", "--stage", "sub"]).is_empty()); + assert!(!std::fs::read_to_string(sup.join(".gitmodules")) + .unwrap() + .contains("submodule")); + assert!(git(&sup, &["diff", "--cached", "--name-only"]).contains(".gitmodules")); + assert!( + sup.join(".git/modules/sub").exists(), + "local module history retained" + ); + let _ = std::fs::remove_dir_all(&base); } + + #[test] + fn adds_submodule_over_git_transport_and_preserves_existing_directories() { + use std::net::{TcpListener, TcpStream}; + let base = + std::env::temp_dir().join(format!("strand-submodule-add-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let source = base.join("source"); + let target = base.join("target"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + git(&source, &["init", "-q", "-b", "main"]); + std::fs::write(source.join("asset.txt"), "source").unwrap(); + git(&source, &["add", "asset.txt"]); + git( + &source, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "initial", + ], + ); + git(&target, &["init", "-q", "-b", "main"]); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut daemon = crate::git_command() + .args([ + "daemon", + "--export-all", + "--reuseaddr", + "--listen=127.0.0.1", + &format!("--port={port}"), + &format!("--base-path={}", base.display()), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let started = std::time::Instant::now(); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!(started.elapsed().as_secs() < 30); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let repo = Repo::discover(&target).unwrap(); + let url = format!("git://127.0.0.1:{port}/source"); + let result = repo.submodule_action( + SubmoduleAction::Add { + url: url.clone(), + path: "vendor/library".into(), + }, + |_| {}, + None, + ); + let _ = daemon.kill(); + let _ = daemon.wait(); + result.unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("vendor/library/asset.txt")).unwrap(), + "source" + ); + assert!(git(&target, &["ls-files", "--stage", "vendor/library"]).starts_with("160000")); + assert!(git(&target, &["show", ":.gitmodules"]).contains(&url)); + let before = git(&target, &["write-tree"]); + assert!(repo + .submodule_action( + SubmoduleAction::Add { + url, + path: "vendor/library".into() + }, + |_| {}, + None + ) + .is_err()); + assert_eq!(git(&target, &["write-tree"]), before); + let cancel = CancelHandle::new(); + cancel.cancel(); + assert!(matches!( + repo.submodule_action( + SubmoduleAction::Sync { + path: "vendor/library".into(), + recursive: true + }, + |_| {}, + Some(&cancel) + ), + Err(Error::Cancelled) + )); + let _ = std::fs::remove_dir_all(base); + } } diff --git a/crates/strand-core/src/tag.rs b/crates/strand-core/src/tag.rs index 4a747c66..3d8843b1 100644 --- a/crates/strand-core/src/tag.rs +++ b/crates/strand-core/src/tag.rs @@ -1,11 +1,31 @@ //! Tag writes — create (lightweight + annotated) and delete. //! //! Tag *reads* live in `refs.rs` (`collect_tags`); this is the mutating side. -//! All ops go through `git2`, matching the branch-write policy (stable Rust -//! API, no spawn overhead). Pushing tags to a remote is a separate concern — -//! `git push` doesn't send tags by default — and is tracked as future work. +//! Creation uses system Git for inherited signing and agent/key configuration. -use crate::{error::Result, repo::Repo}; +use serde::{Deserialize, Serialize}; +use crate::{Error, Result, repo::Repo, signing::{SigningMode, git_bool}}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TagVerificationStatus { Unsigned, Verified, Failed } + +#[derive(Debug, Serialize, Deserialize)] +pub struct TagVerification { + pub oid: String, + pub status: TagVerificationStatus, + pub output: String, +} + +fn run_tag_git(repo: &Repo, args: &[&str]) -> Result { + crate::git_output::capture(crate::git_command().current_dir(&repo.path) + .env("GIT_TERMINAL_PROMPT", "0").env("GIT_EDITOR", ":") + .args(crate::GIT_SAFE_CONFIG).args(args)) +} + +fn transcript(output: &std::process::Output) -> String { + format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)).trim().to_owned() +} impl Repo { /// Create a tag pointing at `target` (any revspec git understands — an @@ -13,7 +33,8 @@ impl Repo { /// /// When `message` is `Some` and non-empty, an **annotated** tag is created /// (the tagger is pulled from the repo's git config — `user.name` / - /// `user.email`); otherwise a **lightweight** tag. `force` mirrors + /// `user.email`); otherwise a **lightweight** tag. Inherited signing may + /// require an annotation. `force` mirrors /// `git tag -f`: overwrite an existing tag of the same name instead of /// erroring. pub fn create_tag( @@ -23,24 +44,80 @@ impl Repo { message: Option<&str>, force: bool, ) -> Result<()> { + self.create_tag_with_signing(name, target, message, force, SigningMode::Inherit) + } + + pub fn create_tag_with_signing( + &self, name: &str, target: Option<&str>, message: Option<&str>, force: bool, signing: SigningMode, + ) -> Result<()> { + if name.starts_with('-') || !git2::Reference::is_valid_name(&format!("refs/tags/{name}")) { + return Err(Error::Other("Invalid tag name".into())); + } let repo = self.git2()?; - let rev = target.unwrap_or("HEAD"); - // Peel to a commit so both flavours tag the commit (not, say, the - // tag object of an already-annotated revspec). - let object = repo.revparse_single(rev)?.peel(git2::ObjectType::Commit)?; - - match message { - Some(msg) if !msg.trim().is_empty() => { - let tagger = repo.signature()?; - repo.tag(name, &object, &tagger, msg, force)?; - } - _ => { - repo.tag_lightweight(name, &object, force)?; - } + let object = repo.revparse_single(target.unwrap_or("HEAD"))?.peel(git2::ObjectType::Commit)?; + let oid = object.id().to_string(); + let message = message.filter(|msg| !msg.trim().is_empty()); + let signed = match signing { + SigningMode::Sign => true, + SigningMode::Unsigned => false, + SigningMode::Inherit => git_bool(self, "tag.gpgsign")? + || (message.is_some() && git_bool(self, "tag.forceSignAnnotated")?), + }; + if signed && message.is_none() { + return Err(Error::Other("A signed tag requires an annotation message".into())); + } + // Verbatim cleanup requires a final LF before Git appends the signature. + let file = message.map(|message| crate::commit::temp_message_file(&format!("{}\n", message.trim_end_matches(['\r', '\n'])))).transpose()?; + let file_arg = file.as_ref().map(|path| path.to_string_lossy().into_owned()); + let mut args = vec!["tag"]; + match signing { + SigningMode::Sign => args.push("--sign"), + SigningMode::Unsigned => args.push("--no-sign"), + SigningMode::Inherit => {}, + } + if force { args.push("--force"); } + if let Some(file_arg) = file_arg.as_deref() { + // --file already creates an annotation. Explicit --annotate + // suppresses tag.forceSignAnnotated, so use it only for the + // operation's unsigned override (alongside --no-sign). + if matches!(signing, SigningMode::Unsigned) { args.push("--annotate"); } + args.extend(["--cleanup=verbatim", "--file", file_arg]); + } + args.extend(["--", name, &oid]); + let result = run_tag_git(self, &args); + if let Some(file) = file { let _ = std::fs::remove_file(file); } + let output = result?; + if !output.status.success() { + let text = transcript(&output); + return Err(Error::Other(if text.is_empty() { "Git could not create the tag".into() } else { text })); } Ok(()) } + /// Verify the immutable tag object selected by this exact short tag name. + /// No graph-wide verification or trusting a mutable ref after resolution. + pub fn verify_tag(&self, name: &str) -> Result { + let repo = self.git2()?; + let reference = repo.find_reference(&format!("refs/tags/{name}"))?; + let target = reference.resolve()?.target().ok_or_else(|| Error::Other("Tag has no target".into()))?; + let object = repo.find_object(target, None)?; + let oid = object.id().to_string(); + let tag = object.as_tag(); + let signed = tag.is_some_and(|tag| { + let message = String::from_utf8_lossy(tag.message_bytes().unwrap_or_default()); + ["-----BEGIN PGP SIGNATURE-----", "-----BEGIN SSH SIGNATURE-----", "-----BEGIN SIGNED MESSAGE-----"] + .iter().any(|marker| message.contains(marker)) + }); + if !signed { + return Ok(TagVerification { oid, status: TagVerificationStatus::Unsigned, output: "This tag has no signature.".into() }); + } + let output = run_tag_git(self, &["verify-tag", "--raw", &oid])?; + Ok(TagVerification { + oid, status: if output.status.success() { TagVerificationStatus::Verified } else { TagVerificationStatus::Failed }, + output: transcript(&output), + }) + } + /// Delete a tag by short name (e.g. `v1.0.0`). Local only — a tag already /// pushed to a remote stays there until deleted on the remote too. pub fn delete_tag(&self, name: &str) -> Result<()> { @@ -66,6 +143,9 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let repo = git2::Repository::init(&dir).unwrap(); + for (key, value) in [("user.name", "Test"), ("user.email", "test@example.com"), ("tag.gpgsign", "false"), ("tag.forcesignannotated", "false")] { + repo.config().unwrap().set_str(key, value).unwrap(); + } { let sig = git2::Signature::now("Test", "test@example.com").unwrap(); let tree_oid = { diff --git a/crates/strand-core/src/tree.rs b/crates/strand-core/src/tree.rs index a2e60422..5c70cd9d 100644 --- a/crates/strand-core/src/tree.rs +++ b/crates/strand-core/src/tree.rs @@ -22,6 +22,9 @@ pub struct WorkTreeEntry { /// because ignored files are not working-tree changes. #[serde(default)] pub ignored: bool, + /// Tracked in Git but intentionally absent from this sparse working tree. + #[serde(default)] + pub excluded: bool, } impl Repo { @@ -81,6 +84,7 @@ impl Repo { path, status: None, ignored: true, + excluded: false, }); } entries.sort_unstable_by(|a, b| a.path.cmp(&b.path)); @@ -105,6 +109,7 @@ impl Repo { path: format!("{root}{name}"), status: None, ignored: false, + excluded: false, }); } } @@ -126,11 +131,11 @@ pub(crate) fn from_index_and_statuses( // Start from the index — the canonical set of tracked paths. A // BTreeMap keeps the output path-sorted and dedupes conflict entries // (which appear once per stage). - let mut map: BTreeMap, bool)> = BTreeMap::new(); + let mut map: BTreeMap, bool, bool)> = BTreeMap::new(); let index = repo.index()?; for entry in index.iter() { if let Ok(p) = std::str::from_utf8(&entry.path) { - map.entry(p.to_string()).or_insert((None, false)); + map.entry(p.to_string()).or_insert((None, false, entry.flags_extended & (1 << 14) != 0)); } } @@ -141,16 +146,20 @@ pub(crate) fn from_index_and_statuses( for e in statuses.iter() { let Some(path) = e.path() else { continue }; let s = e.status(); + // libgit2 reports absent skip-worktree entries as deletions. These + // remain tracked; only a real staged change overrides their identity. + if map.get(path).is_some_and(|(_, _, excluded)| *excluded) + && s == git2::Status::WT_DELETED { continue; } if s.is_ignored() { - map.entry(path.to_string()).or_insert((None, true)); + map.entry(path.to_string()).or_insert((None, true, false)); continue; } - map.insert(path.to_string(), (Some(classify(s)), false)); + map.insert(path.to_string(), (Some(classify(s)), false, false)); } Ok(map .into_iter() - .map(|(path, (status, ignored))| WorkTreeEntry { path, status, ignored }) + .map(|(path, (status, ignored, excluded))| WorkTreeEntry { path, status, ignored, excluded }) .collect()) } @@ -219,7 +228,7 @@ fn ignored_boundaries( ) -> Result> { let mut map = entries .into_iter() - .map(|entry| (entry.path, (entry.status, entry.ignored))) + .map(|entry| (entry.path, (entry.status, entry.ignored, entry.excluded))) .collect::>(); let mut pending = vec![(workdir.to_path_buf(), String::new())]; @@ -254,7 +263,7 @@ fn ignored_boundaries( } else { relative }; - map.insert(path, (None, true)); + map.insert(path, (None, true, false)); continue; } if file_type.is_dir() && !file_type.is_symlink() && !child.path().join(".git").exists() { @@ -265,10 +274,11 @@ fn ignored_boundaries( Ok(map .into_iter() - .map(|(path, (status, ignored))| WorkTreeEntry { + .map(|(path, (status, ignored, excluded))| WorkTreeEntry { path, status, ignored, + excluded, }) .collect()) } diff --git a/crates/strand-core/src/user_actions.rs b/crates/strand-core/src/user_actions.rs new file mode 100644 index 00000000..763cfe46 --- /dev/null +++ b/crates/strand-core/src/user_actions.rs @@ -0,0 +1,253 @@ +//! Personally configured actions. Argument boundaries exist before substitution; +//! repository values are never parsed as command syntax or substituted twice. +use serde::{Deserialize, Serialize}; + +use crate::{Error, Repo, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UserAction { + pub id: String, + pub name: String, + pub scope: String, + pub executable: String, + pub args: Vec, + pub cwd: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ActionTarget { + Repository, + Ref { reference: String, oid: String }, + File { file: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ActionContext { + pub path: String, + pub target: ActionTarget, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ActionPreview { + pub executable: String, + pub args: Vec, + pub cwd: String, +} + +fn invalid(message: &str) -> Error { + Error::Other(message.into()) +} + +/// Expand only template text. Escaped braces allow literal script/JSON arguments. +fn substitute(template: &str, vars: &[(&str, String)]) -> Result { + let mut out = String::new(); + let mut rest = template; + while !rest.is_empty() { + if rest.starts_with("{{") { + out.push('{'); + rest = &rest[2..]; + } else if rest.starts_with("}}") { + out.push('}'); + rest = &rest[2..]; + } else if rest.starts_with('{') { + let end = rest + .find('}') + .ok_or_else(|| invalid("Unclosed action placeholder"))?; + let key = &rest[1..end]; + let value = vars + .iter() + .find(|(name, _)| *name == key) + .ok_or_else(|| invalid(&format!("Unavailable action placeholder: {{{key}}}")))?; + out.push_str(&value.1); + rest = &rest[end + 1..]; + } else { + let ch = rest.chars().next().unwrap(); + out.push(ch); + rest = &rest[ch.len_utf8()..]; + } + } + Ok(out) +} + +impl Repo { + pub fn preview_user_action( + &self, + action: &UserAction, + context: &ActionContext, + ) -> Result { + if action.name.trim().is_empty() + || action.name.len() > 120 + || action.args.len() > 128 + || action.executable.trim().is_empty() + || action.executable.contains('\0') + || action.executable.len() > 4096 + || action.args.iter().map(String::len).sum::() > 24_000 + || action.args.iter().any(|arg| arg.contains('\0')) + { + return Err(invalid("Invalid action: use a name, a literal executable, and at most 128 arguments / 24 KB")); + } + let root = self.path().canonicalize()?; + let mut cwd = root.clone(); + let mut vars = vec![("repo", root.to_string_lossy().into_owned())]; + match &context.target { + ActionTarget::Repository if action.scope == "repository" => {} + ActionTarget::Ref { reference, oid } if action.scope == "ref" => { + if !reference.starts_with("refs/") { + return Err(invalid("Select a qualified branch or tag ref")); + } + let current = self + .git2()? + .find_reference(reference)? + .peel_to_commit()? + .id() + .to_string(); + if ¤t != oid { + return Err(invalid( + "Selected ref changed. Close this preview and select it again.", + )); + } + vars.extend([("ref", reference.clone()), ("oid", current)]); + } + ActionTarget::File { file } if action.scope == "file" => { + let full = self.workdir_path(file)?.canonicalize()?; + if !full.is_file() { + return Err(invalid("Select an existing working-tree file")); + } + // A replaced symlink must never redirect a preview outside this checkout. + if !full.starts_with(&root) { + return Err(invalid("File escapes the working tree")); + } + if action.cwd == "file-parent" { + cwd = full.parent().unwrap().to_owned(); + } + vars.extend([ + ("file", full.to_string_lossy().into_owned()), + ("relativeFile", file.clone()), + ]); + } + _ => return Err(invalid("Action scope does not match the selected context")), + } + if action.cwd != "repository" && !(action.cwd == "file-parent" && action.scope == "file") { + return Err(invalid( + "Working directory must be the repository or selected file's parent", + )); + } + let mut args = Vec::new(); + if std::path::Path::new(&action.executable) + .file_stem() + .is_some_and(|name| name.eq_ignore_ascii_case("git")) + { + args.extend(crate::GIT_SAFE_CONFIG.iter().map(|arg| arg.to_string())); + } + for arg in &action.args { + args.push(substitute(arg, &vars)?); + } + if args.iter().map(String::len).sum::() > 28_000 { + return Err(invalid("Resolved arguments exceed 28 KB")); + } + Ok(ActionPreview { + executable: action.executable.clone(), + args, + cwd: cwd.to_string_lossy().into_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn action(scope: &str, args: &[&str]) -> UserAction { + UserAction { + id: "test".into(), + name: "Test".into(), + scope: scope.into(), + executable: "probe".into(), + args: args.iter().map(|s| s.to_string()).collect(), + cwd: "repository".into(), + } + } + + #[test] + fn substitution_preserves_boundaries_and_does_not_reexpand_values() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("repo space & %PATH% {oid}"); + std::fs::create_dir(&dir).unwrap(); + git2::Repository::init(&dir).unwrap(); + let file = "file & %PATH% {repo}.txt"; + std::fs::write(dir.join(file), "text").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + let context = ActionContext { + path: dir.to_string_lossy().into_owned(), + target: ActionTarget::File { file: file.into() }, + }; + let mut definition = action( + "file", + &["--", "{relativeFile}", "prefix={file}", "", "{{literal}}"], + ); + definition.cwd = "file-parent".into(); + definition.executable = dir.join("tool {repo}").to_string_lossy().into_owned(); + let preview = repo.preview_user_action(&definition, &context).unwrap(); + assert_eq!(preview.executable, definition.executable); + assert_eq!(preview.args[1], file); + assert!(preview.args[2].ends_with(file)); + assert_eq!(&preview.args[3..], &["", "{literal}"]); + assert_eq!(preview.cwd, dir.canonicalize().unwrap().to_string_lossy()); + definition.args = vec!["{ref}".into()]; + assert!(repo.preview_user_action(&definition, &context).is_err()); + let outside = ActionContext { + target: ActionTarget::File { + file: "../outside".into(), + }, + ..context.clone() + }; + assert!(repo + .preview_user_action(&action("file", &[]), &outside) + .is_err()); + std::fs::remove_file(dir.join(file)).unwrap(); + assert!(repo + .preview_user_action(&action("file", &[]), &context) + .is_err()); + } + + #[test] + fn stale_refs_and_wrong_scopes_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let git = git2::Repository::init(dir.path()).unwrap(); + let tree = git.treebuilder(None).unwrap().write().unwrap(); + let tree = git.find_tree(tree).unwrap(); + let sig = git2::Signature::now("Test", "test@example.com").unwrap(); + let oid = git + .commit(Some("refs/heads/main"), &sig, &sig, "one", &tree, &[]) + .unwrap(); + let context = ActionContext { + path: dir.path().to_string_lossy().into_owned(), + target: ActionTarget::Ref { + reference: "refs/heads/main".into(), + oid: oid.to_string(), + }, + }; + let repo = Repo::discover(dir.path()).unwrap(); + assert!(repo + .preview_user_action(&action("ref", &["{ref}", "{oid}"]), &context) + .is_ok()); + assert!(repo + .preview_user_action(&action("file", &[]), &context) + .is_err()); + git.commit( + Some("refs/heads/main"), + &sig, + &sig, + "two", + &tree, + &[&git.find_commit(oid).unwrap()], + ) + .unwrap(); + let repo = Repo::discover(dir.path()).unwrap(); + assert!(repo + .preview_user_action(&action("ref", &[]), &context) + .is_err()); + } +} diff --git a/crates/strand-core/src/watch.rs b/crates/strand-core/src/watch.rs index e29ed408..ccbceed8 100644 --- a/crates/strand-core/src/watch.rs +++ b/crates/strand-core/src/watch.rs @@ -99,7 +99,8 @@ fn changes_file_inventory(event: ¬ify::Event, git_dir: &Path) -> bool { event.paths.iter().any(|path| { if let Ok(relative) = path.strip_prefix(git_dir) { // Ref/index replacements do not change the Files inventory. - relative == Path::new("info/exclude") || relative == Path::new("config") + relative == Path::new("info/exclude") || relative == Path::new("info/sparse-checkout") + || relative == Path::new("config") || relative == Path::new("config.worktree") } else { structural || path.file_name().is_some_and(|name| name == ".gitignore") } @@ -148,6 +149,13 @@ fn relevant_path(path: &Path, git_dir: &Path) -> bool { | "rebase-apply" | "info" | "config" + | "BISECT_START" + | "BISECT_LOG" + | "BISECT_TERMS" + | "BISECT_HEAD" + | "BISECT_EXPECTED_REV" + + | "config.worktree" ) } @@ -176,6 +184,10 @@ mod tests { "/repo/.git/refs/heads/main", "/repo/.git/MERGE_HEAD", "/repo/.git/rebase-merge/done", + "/repo/.git/rebase-apply/applying", + "/repo/.git/rebase-apply/next", + "/repo/.git/BISECT_START", + "/repo/.git/BISECT_LOG", ] { assert!(relevant_path(&PathBuf::from(p), &git_dir()), "{p} should refresh"); } diff --git a/crates/strand-core/tests/clone_recursive.rs b/crates/strand-core/tests/clone_recursive.rs new file mode 100644 index 00000000..f1bede72 --- /dev/null +++ b/crates/strand-core/tests/clone_recursive.rs @@ -0,0 +1,38 @@ +use std::path::Path; +use strand_core::network::{clone_with_options, CloneOptions}; + +fn git(path: &Path, args: &[&str]) { + let result = std::process::Command::new("git").current_dir(path) + .args(["-c", "protocol.file.allow=always", "-c", "commit.gpgsign=false", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com"]) + .args(args).output().unwrap(); + assert!(result.status.success(), "{args:?}: {}", String::from_utf8_lossy(&result.stderr)); +} + +#[test] +fn recursive_clone_initializes_nested_modules() { + // This integration-test process owns its environment; other test binaries + // cannot inherit this test-only local transport allowance. + std::env::set_var("GIT_CONFIG_COUNT", "1"); + std::env::set_var("GIT_CONFIG_KEY_0", "protocol.file.allow"); + std::env::set_var("GIT_CONFIG_VALUE_0", "always"); + let base = std::env::temp_dir().join(format!("strand-recursive-clone-{}", std::process::id())); + for name in ["leaf", "module", "source"] { + let path = base.join(name); + std::fs::create_dir_all(&path).unwrap(); + git(&path, &["init", "-b", "main"]); + std::fs::write(path.join("file.txt"), "fixture\n").unwrap(); + git(&path, &["add", "."]); + git(&path, &["commit", "-m", "fixture"]); + } + git(&base.join("module"), &["submodule", "add", "../leaf", "nested"]); + git(&base.join("module"), &["commit", "-am", "add nested"]); + git(&base.join("source"), &["submodule", "add", "../module", "module"]); + git(&base.join("source"), &["commit", "-am", "add module"]); + let dest = base.join("clone"); + clone_with_options(base.join("source").to_str().unwrap(), dest.to_str().unwrap(), &CloneOptions { + recurse_submodules: true, ..Default::default() + }, |_| {}, None).unwrap(); + assert!(dest.join("module/file.txt").exists()); + assert!(dest.join("module/nested/file.txt").exists()); + assert!(strand_core::Repo::discover(dest).unwrap().snapshot().unwrap().status.is_empty()); +} diff --git a/crates/strand-core/tests/clone_scope.rs b/crates/strand-core/tests/clone_scope.rs new file mode 100644 index 00000000..06256d02 --- /dev/null +++ b/crates/strand-core/tests/clone_scope.rs @@ -0,0 +1,140 @@ +use std::path::{Path, PathBuf}; +use strand_core::{network::{clone_with_options, CancelHandle, CloneFilter, CloneOptions, HistoryExpansion}, Repo}; + +fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git").current_dir(dir) + .args(["-c", "core.autocrlf=false", "-c", "commit.gpgsign=false"]) + .args(args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().trim().to_owned() +} + +fn fixture() -> (PathBuf, PathBuf, String) { + let base = std::env::temp_dir().join(format!("strand-clone-scope-{}-{:?}", std::process::id(), std::thread::current().id())); + std::fs::create_dir_all(&base).unwrap(); + let source = base.join("source"); + std::fs::create_dir_all(&source).unwrap(); + git(&source, &["init", "-b", "main"]); + git(&source, &["config", "user.name", "Fixture"]); + git(&source, &["config", "user.email", "fixture@example.com"]); + git(&source, &["config", "uploadpack.allowFilter", "true"]); + for n in 0..5 { + std::fs::write(source.join("file.txt"), format!("version {n}\n")).unwrap(); + git(&source, &["add", "."]); + git(&source, &["commit", "-m", &format!("commit {n}")]); + } + git(&source, &["branch", "topic"]); + let url = format!("file:///{}", source.to_string_lossy().replace('\\', "/").trim_start_matches('/')); + (base, source, url) +} + +#[test] +fn shallow_clone_choices_deepen_and_unshallow_preserve_work() { + let (base, _, url) = fixture(); + let dest = base.join("shallow"); + let mut updates = 0; + clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions { + branch: Some("topic".into()), depth: Some(1), single_branch: true, ..Default::default() + }, |_| updates += 1, None).unwrap(); + assert!(updates > 0); + let repo = Repo::discover(&dest).unwrap(); + assert_eq!(repo.meta().unwrap().branch, "topic"); + assert_eq!(repo.log(20).unwrap().len(), 1); + assert!(repo.diff_commit("HEAD").unwrap()[0].patch.contains("version 4")); + assert!(repo.diff_commit_file("HEAD", "file.txt").unwrap()[0].patch.contains("version 4")); + assert_eq!(repo.blame("file.txt").unwrap()[0].author, "Fixture"); + assert!(repo.snapshot().unwrap().status.is_empty()); + assert!(repo.clone_scope().unwrap().shallow); + assert_eq!(repo.clone_scope().unwrap().remotes[0].fetch_refspecs, ["+refs/heads/topic:refs/remotes/origin/topic"]); + std::fs::write(dest.join("file.txt"), "staged\n").unwrap(); + repo.stage_path("file.txt").unwrap(); + std::fs::write(dest.join("file.txt"), "unstaged\n").unwrap(); + repo.expand_history("origin", HistoryExpansion::Deepen { commits: 2 }, |_| {}, None).unwrap(); + assert_eq!(Repo::discover(&dest).unwrap().log(20).unwrap().len(), 3); + repo.expand_history("origin", HistoryExpansion::Unshallow, |_| {}, None).unwrap(); + let repo = Repo::discover(&dest).unwrap(); + assert!(!repo.clone_scope().unwrap().shallow); + assert_eq!(repo.log(20).unwrap().len(), 5); + assert_eq!(git(&dest, &["show", ":file.txt"]), "staged"); + assert_eq!(std::fs::read_to_string(dest.join("file.txt")).unwrap(), "unstaged\n"); + assert!(repo.expand_history("origin", HistoryExpansion::Unshallow, |_| {}, None).is_err()); +} + +#[test] +fn external_shallow_and_partial_repositories_open_and_inspect() { + let (base, _, url) = fixture(); + let dest = base.join("external"); + git(&base, &["clone", "--depth=1", "--filter=blob:none", "--no-single-branch", &url, dest.to_str().unwrap()]); + let repo = Repo::discover(&dest).unwrap(); + let scope = repo.clone_scope().unwrap(); + assert!(scope.shallow); + assert_eq!(scope.remotes[0].filter.as_deref(), Some("blob:none")); + assert_eq!(scope.remotes[0].fetch_refspecs, ["+refs/heads/*:refs/remotes/origin/*"]); + assert!(repo.snapshot().unwrap().status.is_empty()); + assert!(repo.diff_unstaged().unwrap().is_empty()); + assert_eq!(repo.log(20).unwrap().len(), 1); +} + +#[test] +fn partial_clone_fetches_historical_content_on_demand() { + let (base, source, url) = fixture(); + let dest = base.join("partial"); + clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions { + filter: Some(CloneFilter::BlobNone), ..Default::default() + }, |_| {}, None).unwrap(); + let missing = git(&dest, &["rev-list", "--objects", "--all", "--missing=print"]); + assert!(missing.lines().any(|line| line.starts_with('?')), "fixture must omit real objects"); + let old = git(&source, &["rev-parse", "HEAD~3"]); + let repo = Repo::discover(&dest).unwrap(); + assert!(repo.file_content("file.txt", Some(&old)).unwrap().text.contains("version 1")); + assert!(!repo.diff_commit(&old).unwrap().is_empty()); + assert!(repo.diff_since(&old).unwrap()[0].patch.contains("version 4")); + assert_eq!(repo.blame("file.txt").unwrap()[0].content, "version 4"); +} + +#[test] +fn invalid_options_and_pre_cancel_do_not_create_a_destination() { + let (base, _, url) = fixture(); + let dest = base.join("invalid"); + for options in [ + CloneOptions { depth: Some(0), ..Default::default() }, + CloneOptions { branch: Some("--upload-pack=evil".into()), ..Default::default() }, + CloneOptions { branch: Some("bad\nbranch".into()), ..Default::default() }, + ] { + assert!(clone_with_options(&url, dest.to_str().unwrap(), &options, |_| {}, None).is_err()); + assert!(!dest.exists()); + } + let cancel = CancelHandle::new(); + cancel.cancel(); + assert!(matches!(clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions::default(), |_| {}, Some(&cancel)), Err(strand_core::Error::Cancelled))); + assert!(!dest.exists()); +} + +#[test] +fn cancellation_stops_a_live_http_clone_and_its_transport_child() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/repository", listener.local_addr().unwrap()); + let (accepted_tx, accepted_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + accepted_tx.send(()).unwrap(); + let _ = release_rx.recv(); + drop(stream); + }); + let dest = std::env::temp_dir().join(format!("strand-clone-cancel-{}", std::process::id())); + let cancel = CancelHandle::new(); + let worker_cancel = cancel.clone(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = clone_with_options(&url, dest.to_str().unwrap(), &CloneOptions::default(), |_| {}, Some(&worker_cancel)); + done_tx.send(result).unwrap(); + }); + accepted_rx.recv_timeout(std::time::Duration::from_secs(15)).unwrap(); + cancel.cancel(); + let result = done_rx.recv_timeout(std::time::Duration::from_secs(10)); + release_tx.send(()).unwrap(); + server.join().unwrap(); + worker.join().unwrap(); + assert!(matches!(result.unwrap(), Err(strand_core::Error::Cancelled))); +} diff --git a/crates/strand-core/tests/sparse_checkout.rs b/crates/strand-core/tests/sparse_checkout.rs new file mode 100644 index 00000000..12adb226 --- /dev/null +++ b/crates/strand-core/tests/sparse_checkout.rs @@ -0,0 +1,180 @@ +use std::path::{Path, PathBuf}; +use strand_core::Repo; + +fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git").current_dir(dir) + .args(["-c", "core.autocrlf=false", "-c", "commit.gpgsign=false"]) + .args(args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().trim().into() +} +fn fixture() -> PathBuf { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!("strand-sparse-{}-{:?}-{}", std::process::id(), std::thread::current().id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "Fixture"]); + git(&dir, &["config", "user.email", "fixture@example.com"]); + for name in ["keep/a.txt", "omit/b.txt", "space name/nested/c.txt", "root.txt"] { + std::fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap(); + std::fs::write(dir.join(name), "original\n").unwrap(); + } + git(&dir, &["add", "."]); + git(&dir, &["commit", "-m", "fixture"]); + dir +} + +#[test] +fn external_sparse_indexes_open_without_becoming_deletions_or_rewriting_index() { + for sparse in [false, true] { + let dir = fixture().join(if sparse { "compressed" } else { "expanded" }); + let source = dir.parent().unwrap(); + git(source, &["clone", "--no-local", source.to_str().unwrap(), dir.to_str().unwrap()]); + git(&dir, &["sparse-checkout", "set", "--cone", if sparse { "--sparse-index" } else { "--no-sparse-index" }, "keep"]); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + let repo = Repo::discover(&dir).unwrap(); + assert!(repo.sparse_checkout().unwrap().enabled); + assert_eq!(repo.sparse_checkout().unwrap().directories, ["keep"]); + let snapshot = repo.snapshot().unwrap(); + assert!(snapshot.status.is_empty(), "{:?}", snapshot.status); + assert!(snapshot.work_tree.iter().find(|entry| entry.path == "omit/b.txt").unwrap().excluded); + assert!(repo.diff_unstaged().unwrap().is_empty()); + assert!(repo.diff_staged().unwrap().is_empty()); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + std::fs::remove_file(dir.join("keep/a.txt")).unwrap(); + let status = Repo::discover(&dir).unwrap().status().unwrap(); + assert_eq!(status.len(), 1); + assert_eq!(status[0].path, "keep/a.txt"); + } +} + +#[test] +fn sparse_stage_hunks_commit_checkout_and_discard_preserve_excluded_files() { + let dir = fixture(); + git(&dir, &["branch", "other"]); + git(&dir, &["sparse-checkout", "set", "--cone", "--sparse-index", "keep"]); + std::fs::write(dir.join("keep/a.txt"), "edited\n").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + let patch = repo.diff_unstaged().unwrap().remove(0).patch; + repo.apply_patch(&patch, strand_core::apply::ApplyTarget::Index).unwrap(); + assert!(Repo::discover(&dir).unwrap().diff_staged().unwrap()[0].patch.contains("edited")); + Repo::discover(&dir).unwrap().unstage_path("keep/a.txt").unwrap(); + Repo::discover(&dir).unwrap().stage_paths(&["keep/a.txt".into()]).unwrap(); + git(&dir, &["config", "commit.gpgsign", "false"]); + Repo::discover(&dir).unwrap().commit("sparse edit", None, false).unwrap(); + assert_eq!(git(&dir, &["show", "HEAD:omit/b.txt"]), "original"); + assert!(!dir.join("omit/b.txt").exists()); + assert!(git(&dir, &["ls-files", "--sparse"]).lines().any(|line| line == "omit/")); + Repo::discover(&dir).unwrap().checkout_branch("other").unwrap(); + assert!(!dir.join("omit/b.txt").exists()); + assert_eq!(std::fs::read_to_string(dir.join("keep/a.txt")).unwrap().trim(), "original"); + std::fs::write(dir.join("keep/a.txt"), "discard this\n").unwrap(); + std::fs::write(dir.join("loose.txt"), "new\n").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + assert!(repo.diff_unstaged().unwrap().iter().any(|diff| diff.path == "loose.txt")); + repo.discard_paths(&["keep/a.txt".into(), "loose.txt".into()]).unwrap(); + assert!(Repo::discover(&dir).unwrap().status().unwrap().is_empty()); +} + +#[test] +fn ignored_untracked_staged_and_invalid_selection_refuse_without_mutation() { + let dir = fixture(); + std::fs::write(dir.join(".gitignore"), "omit/cache/\n").unwrap(); + git(&dir, &["add", ".gitignore"]); + git(&dir, &["commit", "-m", "ignore cache"]); + std::fs::create_dir_all(dir.join("omit/cache")).unwrap(); + std::fs::write(dir.join("omit/cache/precious.txt"), "preserve\n").unwrap(); + let repo = Repo::discover(&dir).unwrap(); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + assert!(repo.set_sparse_checkout(&["keep".into()], true).unwrap_err().to_string().contains("Ignored")); + for name in ["../outside", "--cone", "keep\nother", ".git", "unknown"] { + assert!(repo.set_sparse_checkout(&[name.into()], true).is_err()); + } + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + assert!(dir.join("omit/cache/precious.txt").exists()); + // Keeping the ignored directory's parent is safe. + repo.set_sparse_checkout(&["omit".into()], true).unwrap(); + std::fs::write(dir.join("root.txt"), "staged\n").unwrap(); + git(&dir, &["add", "root.txt"]); + std::fs::write(dir.join("root.txt"), "unstaged\n").unwrap(); + std::fs::write(dir.join("untracked.txt"), "loose\n").unwrap(); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + assert!(Repo::discover(&dir).unwrap().disable_sparse_checkout().is_err()); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + assert_eq!(git(&dir, &["show", ":root.txt"]), "staged"); + assert_eq!(std::fs::read_to_string(dir.join("root.txt")).unwrap(), "unstaged\n"); + assert!(dir.join("untracked.txt").exists()); +} + +#[test] +fn external_non_cone_inspection_and_linked_worktree_isolation() { + let dir = fixture(); + git(&dir, &["sparse-checkout", "set", "--no-cone", "/keep/"]); + let repo = Repo::discover(&dir).unwrap(); + assert!(!repo.sparse_checkout().unwrap().cone); + assert!(repo.set_sparse_checkout(&["omit".into()], false).is_err()); + repo.disable_sparse_checkout().unwrap(); + let linked = dir.with_extension("linked"); + git(&dir, &["worktree", "add", "-b", "linked", linked.to_str().unwrap()]); + Repo::discover(&linked).unwrap().set_sparse_checkout(&["keep".into()], true).unwrap(); + assert!(!linked.join("omit/b.txt").exists()); + assert!(dir.join("omit/b.txt").exists()); + assert!(!Repo::discover(&dir).unwrap().sparse_checkout().unwrap().enabled); + assert!(Repo::discover(&linked).unwrap().snapshot().unwrap().status.is_empty()); +} + +#[test] +fn sparse_file_diff_limits_work_to_a_literal_tracked_or_untracked_path() { + let dir = fixture(); + std::fs::write(dir.join("keep/[one].txt"), "original\n").unwrap(); + git(&dir, &["add", "."]); + git(&dir, &["commit", "-m", "literal path"]); + Repo::discover(&dir).unwrap().set_sparse_checkout(&["keep".into()], true).unwrap(); + for path in ["root.txt", "keep/[one].txt", "keep/[new].txt"] { + std::fs::write(dir.join(path), "edited\n").unwrap(); + } + for path in ["keep/[one].txt", "keep/[new].txt"] { + let diffs = Repo::discover(&dir).unwrap().diff_workdir_file(path).unwrap(); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, path); + assert!(diffs[0].patch.contains("edited")); + } +} + +#[test] +fn restored_clean_file_is_removed_when_its_directory_is_excluded() { + let dir = fixture(); + Repo::discover(&dir).unwrap().set_sparse_checkout(&["keep".into()], true).unwrap(); + let path = dir.join("keep/a.txt"); + let original = std::fs::read(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert!(Repo::discover(&dir).unwrap().set_sparse_checkout(&["omit".into()], true).is_err()); + std::fs::write(&path, original).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap() + .set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3)).unwrap(); + Repo::discover(&dir).unwrap().set_sparse_checkout(&["omit".into()], true).unwrap(); + assert!(!path.exists(), "Clean files with stale index stat data must be excluded"); + assert!(dir.join("omit/b.txt").exists()); +} + +#[test] +fn cone_selection_round_trip_and_dirty_refusal() { + let dir = fixture(); + let repo = Repo::discover(&dir).unwrap(); + repo.set_sparse_checkout(&["space name/nested".into()], true).unwrap(); + assert!(dir.join("root.txt").exists()); + assert!(dir.join("space name/nested/c.txt").exists()); + assert!(!dir.join("omit/b.txt").exists()); + let repo = Repo::discover(&dir).unwrap(); + assert_eq!(repo.sparse_checkout().unwrap().directories, ["space name/nested"]); + std::fs::write(dir.join("root.txt"), "dirty\n").unwrap(); + let before = std::fs::read(dir.join(".git/index")).unwrap(); + assert!(repo.set_sparse_checkout(&["keep".into()], true).is_err()); + assert!(repo.disable_sparse_checkout().is_err()); + assert_eq!(std::fs::read(dir.join(".git/index")).unwrap(), before); + assert_eq!(std::fs::read_to_string(dir.join("root.txt")).unwrap(), "dirty\n"); + git(&dir, &["restore", "root.txt"]); + Repo::discover(&dir).unwrap().disable_sparse_checkout().unwrap(); + assert!(dir.join("omit/b.txt").exists()); + assert!(Repo::discover(&dir).unwrap().status().unwrap().is_empty()); +} diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 0213b2dd..38707d08 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] } # tauri-plugin-sql, so these direct deps add ~no build cost. sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runtime-tokio"] } sha2 = "0.10" +sha1_smol = "1" base64 = "0.22" flate2 = "1" minisign-verify = "0.2.5" diff --git a/crates/strand-tauri/src/ai/bin.rs b/crates/strand-tauri/src/ai/bin.rs index 1e1ceef4..70c19acd 100644 --- a/crates/strand-tauri/src/ai/bin.rs +++ b/crates/strand-tauri/src/ai/bin.rs @@ -498,15 +498,34 @@ fn join_stream_thread(handle: Option>) -> usize { handle.and_then(|thread| thread.join().ok()).unwrap_or(0) } -struct CapturedOutput { - text: String, - exceeded: bool, +pub(crate) struct CapturedOutput { + pub(crate) text: String, + pub(crate) exceeded: bool, } fn drain_pipe( + pipe: R, + max_bytes: usize, + output_exceeded: Arc, +) -> std::thread::JoinHandle { + drain_pipe_impl(pipe, max_bytes, output_exceeded, true) +} + +/// User-command transcripts preserve whitespace; provider parsers keep their +/// existing trimmed output through `drain_pipe`. +pub(crate) fn drain_pipe_untrimmed( + pipe: R, + max_bytes: usize, + output_exceeded: Arc, +) -> std::thread::JoinHandle { + drain_pipe_impl(pipe, max_bytes, output_exceeded, false) +} + +fn drain_pipe_impl( mut pipe: R, max_bytes: usize, output_exceeded: Arc, + trim: bool, ) -> std::thread::JoinHandle { std::thread::spawn(move || { let mut retained = Vec::new(); @@ -525,14 +544,15 @@ fn drain_pipe( } } } + let text = String::from_utf8_lossy(&retained); CapturedOutput { - text: String::from_utf8_lossy(&retained).trim().to_string(), + text: if trim { text.trim().to_string() } else { text.into_owned() }, exceeded, } }) } -fn join_pipe(handle: Option>) -> CapturedOutput { +pub(crate) fn join_pipe(handle: Option>) -> CapturedOutput { handle .and_then(|h| h.join().ok()) .unwrap_or(CapturedOutput { @@ -626,6 +646,24 @@ pub(crate) struct WindowsJob(windows_sys::Win32::Foundation::HANDLE); #[cfg(windows)] impl WindowsJob { + pub(crate) fn kill_on_close(&self) -> Result<(), String> { + use windows_sys::Win32::System::JobObjects::{ + SetInformationJobObject, JobObjectExtendedLimitInformation, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + // SAFETY: the initialized structure and live job handle match the API. + unsafe { + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if SetInformationJobObject(self.0, JobObjectExtendedLimitInformation, + (&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of::() as u32) == 0 { + return Err("Could not configure action cleanup on app exit".into()); + } + } + Ok(()) + } + pub(crate) fn assign(child: &Child) -> Result { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW}; diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e0ec2642..2fd7991e 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -19,16 +19,19 @@ use strand_azdo_protocol::ServerProfile; use strand_core::{ apply::ApplyTarget, blame::BlameLine, branch::CheckoutOutcome, commit::CommitOutcome, commit_metadata::CommitSignature, + signing::{SigningMode, SigningScope, SigningSettings}, + tag::TagVerification, diff::FileDiff, file::{BlobSource, FileBlob, FileContent, FileHistoryEntry}, - gitconfig::{self, GlobalIdentity}, + gitconfig::{self, GlobalIdentity, RepositoryIdentity}, init::{init_repository, InitOutcome}, maintenance::{MaintenanceOutcome, MaintenanceTask}, + lfs::LfsAction, history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode}, - network::{clone as core_clone, CancelHandle, CloneOutcome, NetworkOutcome, Progress, PullMode, PushMode}, + network::{clone_with_options as core_clone, CancelHandle, CloneOptions, CloneOutcome, CloneScope, HistoryExpansion, NetworkOutcome, Progress, PullMode, PushMode}, reflog::ReflogEntry, refs::{BaseBranch, Refs}, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, snapshot::Snapshot, stash::{Stash, StashOutcome}, - status::FileStatus, submodule::Submodule, tree::WorkTreeEntry, + status::FileStatus, submodule::{Submodule, SubmoduleAction, SubmodulePage}, tree::WorkTreeEntry, worktree::{RestoredWorktree, Worktree, WorktreeArchive, WorktreeHealth, WorktreeStats}, Repo, }; use tauri::ipc::Channel; @@ -69,6 +72,187 @@ impl From for CmdError { pub(crate) type CmdResult = std::result::Result; +#[tauri::command] +pub async fn repo_gitflow_detect() -> CmdResult { + run_blocking("detect Git-flow", || strand_core::gitflow::detect().map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_state(path: String) -> CmdResult { + run_blocking("inspect Git-flow", move || Repo::discover(path)?.gitflow_state().map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_configure(path: String, config: strand_core::gitflow::FlowConfig, enabled: bool, token: String) -> CmdResult { + run_blocking("configure Git-flow", move || Repo::discover(path)?.configure_gitflow(config, enabled, &token).map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_plan(path: String, kind: strand_core::gitflow::FlowKind, action: strand_core::gitflow::FlowAction, name: String) -> CmdResult { + run_blocking("review Git-flow", move || Repo::discover(path)?.plan_gitflow(kind, action, &name).map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_run(path: String, plan: strand_core::gitflow::FlowPlan, on_event: Channel) -> CmdResult { + run_blocking("run Git-flow", move || Repo::discover(path)?.run_gitflow(plan, |text| { let _ = on_event.send(text); }).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_advanced_refs( + path: String, + notes_ref: String, +) -> CmdResult { + run_blocking("inspect advanced refs", move || { + Repo::discover(path)? + .advanced_refs(¬es_ref) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_git_note( + path: String, + notes_ref: String, + revision: String, +) -> CmdResult { + run_blocking("read Git note", move || { + Repo::discover(path)? + .git_note(¬es_ref, &revision) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_git_note_write( + path: String, + notes_ref: String, + object: String, + expected: Option, + message: Option, +) -> CmdResult<()> { + run_blocking("write Git note", move || { + Repo::discover(path)? + .write_git_note(¬es_ref, &object, expected.as_deref(), message.as_deref()) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_replace_review( + path: String, + original: String, + replacement: String, +) -> CmdResult { + run_blocking("review replacement", move || { + Repo::discover(path)? + .review_replacement(&original, &replacement) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_replace_write( + path: String, + original: String, + replacement: Option, + expected: Option, +) -> CmdResult<()> { + run_blocking("write replacement", move || { + Repo::discover(path)? + .write_replacement(&original, replacement.as_deref(), expected.as_deref()) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_tag_edit_review( + path: String, + name: String, + target: String, +) -> CmdResult { + run_blocking("review tag edit", move || { + Repo::discover(path)? + .review_tag_edit(&name, &target) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_tag_edit( + path: String, + name: String, + target: String, + expected: String, + kind: strand_core::advanced_refs::TagEditKind, + message: Option, +) -> CmdResult<()> { + run_blocking("edit tag", move || { + Repo::discover(path)? + .edit_tag(&name, &target, &expected, kind, message.as_deref()) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_tag_published( + path: String, + remote: String, + name: String, +) -> CmdResult { + run_blocking("check published tag", move || { + Repo::discover(path)? + .published_tag(&remote, &name) + .map_err(Into::into) + }) + .await +} + +#[tauri::command] +pub async fn repo_bisect_state(path: String) -> CmdResult { + run_blocking("bisect state", move || Repo::discover(path)?.bisect_state().map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bisect_start(path: String, good: String, bad: String, token: String) -> CmdResult { + run_blocking("start bisect", move || Repo::discover(path)?.bisect_start(&good, &bad, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bisect_action(path: String, action: strand_core::bisect::BisectAction, token: String) -> CmdResult { + run_blocking("bisect action", move || Repo::discover(path)?.bisect_action(action, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_patch_preview(path: String, source: String, target: strand_core::interchange::PatchTarget) -> CmdResult { + run_blocking("preview patch", move || Repo::discover(path)?.preview_patch_import(Path::new(&source), target).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_patch_import(path: String, source: String, target: strand_core::interchange::PatchTarget, token: String) -> CmdResult { + run_blocking("import patch", move || Repo::discover(path)?.import_patch(Path::new(&source), target, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_mailbox_state(path: String) -> CmdResult> { + run_blocking("mailbox state", move || Repo::discover(path)?.mailbox_state().map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_mailbox_action(path: String, action: strand_core::interchange::MailboxAction, token: String) -> CmdResult { + run_blocking("mailbox action", move || Repo::discover(path)?.mailbox_action(action, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bundle_preview(path: String, source: String) -> CmdResult { + run_blocking("verify bundle", move || Repo::discover(path)?.preview_bundle(Path::new(&source)).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bundle_import(path: String, source: String, token: String, source_ref: String, branch: String) -> CmdResult { + run_blocking("import bundle", move || Repo::discover(path)?.import_bundle(Path::new(&source), &token, &source_ref, &branch).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bundle_export(path: String, destination: String, refname: String, prerequisite: Option) -> CmdResult { + run_blocking("export bundle", move || Repo::discover(path)?.export_bundle(Path::new(&destination), &refname, prerequisite.as_deref()).map_err(Into::into)).await +} + #[tauri::command(async)] pub fn repo_terminal_create( path: String, @@ -392,6 +576,50 @@ pub async fn repo_pull_requests(path: String) -> CmdResult { .await } +#[tauri::command(async)] +pub async fn repo_pull_request_completion(path: String, id: u64, enable: bool, strategy: pull_requests::PullRequestMergeStrategy, expected_head: String) -> CmdResult<()> { + run_blocking("pull request completion", move || pull_requests::completion::set(&path, id, enable, strategy, &expected_head).map_err(|message| CmdError { message })).await +} + +#[tauri::command(async)] +pub async fn repo_pull_request_boundaries(path: String, id: u64, expected_head: String, request_id: String) -> CmdResult> { + run_blocking("pull request boundaries", move || pull_requests::evolution::boundaries(&path, id, &expected_head, &request_id).map_err(|message| CmdError { message })).await +} + +#[tauri::command(async)] +pub async fn repo_pull_request_compare_review(path: String, id: u64, from: String, expected_head: String) -> CmdResult { + run_blocking("pull request compare_review", move || pull_requests::evolution::compare(&path, id, &from, &expected_head).map_err(|message| CmdError { message })).await +} + +#[tauri::command(async)] +pub async fn repo_pull_request_feedback(path: String, id: u64, expected_head: String, request_id: String) -> CmdResult { + run_blocking("pull request feedback", move || pull_requests::evolution::feedback(&path, id, &expected_head, &request_id).map_err(|message| CmdError { message })).await +} + +#[tauri::command(async)] +pub async fn repo_pull_request_suggestion_preview(path: String, id: u64, request: pull_requests::evolution::SuggestionRequest, request_id: String) -> CmdResult { + run_blocking("pull request suggestion_preview", move || pull_requests::evolution::preview(&path, id, &request, &request_id).map_err(|message| CmdError { message })).await +} + +#[tauri::command(async)] +pub async fn repo_pull_request_suggestion_apply(path: String, id: u64, request: pull_requests::evolution::SuggestionRequest, expected_preview: pull_requests::evolution::SuggestionPreview, request_id: String) -> CmdResult { + run_blocking("pull request suggestion_apply", move || pull_requests::evolution::apply(&path, id, &request, &expected_preview, &request_id).map_err(|message| CmdError { message })).await +} + +/// One bounded provider page; cancellation never affects provider writes. +#[tauri::command(async)] +pub async fn repo_pull_request_inbox_page(path: String, cursor: Option, request_id: String) -> CmdResult { + run_blocking("pull request inbox page", move || pull_requests::pages::inbox(&path, cursor.as_deref(), &request_id).map_err(|message| CmdError { message })).await +} + +#[tauri::command(async)] +pub async fn repo_pull_request_data_page(path: String, id: u64, expected_head: String, request: pull_requests::pages::Cursor, request_id: String) -> CmdResult { + run_blocking("pull request data page", move || pull_requests::pages::read(&path, id, &expected_head, request, &request_id).map_err(|message| CmdError { message })).await +} + +#[tauri::command] +pub fn repo_pull_request_cancel_read(request_id: String) { pull_requests::pages::cancel(&request_id); } + /// Active pull request for one checked-out branch. This targeted query lets /// automatic following work without loading the full hosted-PR workspace. #[tauri::command(async)] @@ -899,13 +1127,16 @@ pub fn repo_apply_patch(path: String, patch: String, target: String) -> CmdResul } #[tauri::command(async)] -pub fn repo_commit( +pub async fn repo_commit( path: String, subject: String, body: Option, amend: bool, + signing: Option, ) -> CmdResult { - Ok(Repo::discover(&path)?.commit(&subject, body.as_deref(), amend)?) + run_blocking("commit", move || { + Ok(Repo::discover(&path)?.commit_with_signing(&subject, body.as_deref(), amend, signing.unwrap_or_default())?) + }).await } // Network commands run on a blocking thread (they shell out to `git`, which @@ -1094,6 +1325,7 @@ pub async fn repo_branch_pull( pub async fn repo_clone( url: String, dest: String, + options: Option, op_id: Option, on_event: Channel, state: State<'_, AppState>, @@ -1104,6 +1336,7 @@ pub async fn repo_clone( core_clone( &url, &dest, + &options.unwrap_or_default(), |p| { let _ = on_event.send(p); }, @@ -1116,6 +1349,44 @@ pub async fn repo_clone( result } +#[tauri::command(async)] +pub async fn repo_clone_scope(path: String) -> CmdResult { + run_blocking("clone scope", move || Ok(Repo::discover(path)?.clone_scope()?)).await +} + +#[tauri::command(async)] +pub async fn repo_sparse_checkout(path: String) -> CmdResult { + run_blocking("sparse checkout", move || Ok(Repo::discover(path)?.sparse_checkout()?)).await +} + +#[tauri::command(async)] +pub async fn repo_set_sparse_checkout(path: String, directories: Vec, sparse_index: bool) -> CmdResult { + run_blocking("set sparse checkout", move || Ok(Repo::discover(path)?.set_sparse_checkout(&directories, sparse_index)?)).await +} + +#[tauri::command(async)] +pub async fn repo_disable_sparse_checkout(path: String) -> CmdResult { + run_blocking("disable sparse checkout", move || Ok(Repo::discover(path)?.disable_sparse_checkout()?)).await +} + +#[tauri::command(async)] +pub async fn repo_expand_history( + path: String, + remote: String, + expansion: HistoryExpansion, + op_id: Option, + on_event: Channel, + state: State<'_, AppState>, +) -> CmdResult { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("expand history", move || { + Ok(Repo::discover(path)?.expand_history(&remote, expansion, |p| { let _ = on_event.send(p); }, Some(&cancel))?) + }).await; + deregister_op(&state, &op_id); + result +} + /// Kill the in-flight cancellable op registered under `op_id`. A no-op when /// the op already finished (its handle is gone from the registry). /// Deliberately NOT `(async)`: cancellation is a lock + kill signal and must @@ -1185,15 +1456,37 @@ pub async fn repo_submodule_update( init: bool, recursive: bool, on_event: Channel, + op_id: Option, + state: State<'_, AppState>, ) -> CmdResult { - run_blocking("submodule update", move || { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("submodule update", move || { let repo = Repo::discover(&path)?; repo.submodule_update(&paths, init, recursive, |p| { let _ = on_event.send(p); - }) + }, Some(&cancel)) .map_err(CmdError::from) }) - .await + .await; + deregister_op(&state, &op_id); + result +} + +#[tauri::command(async)] +pub async fn repo_submodule_children(path: String, parent: String, offset: usize) -> CmdResult { + run_blocking("submodule children", move || Ok(Repo::discover(&path)?.submodule_children(&parent, offset)?)).await +} + +#[tauri::command(async)] +pub async fn repo_submodule_action(path: String, action: SubmoduleAction, op_id: Option, on_event: Channel, state: State<'_, AppState>) -> CmdResult { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("submodule lifecycle", move || { + Repo::discover(&path)?.submodule_action(action, |p| { let _ = on_event.send(p); }, Some(&cancel)).map_err(CmdError::from) + }).await; + deregister_op(&state, &op_id); + result } #[tauri::command(async)] @@ -1497,15 +1790,85 @@ pub async fn repo_maintenance( } #[tauri::command(async)] -pub fn repo_tag_create( +pub async fn repo_user_action_preview( + action: strand_core::user_actions::UserAction, + context: strand_core::user_actions::ActionContext, +) -> CmdResult { + run_blocking("user action preview", move || { + crate::user_actions::preview(&action, &context).map_err(|message| CmdError { message }) + }).await +} + +#[tauri::command(async)] +pub async fn repo_user_action_run( + action: strand_core::user_actions::UserAction, + context: strand_core::user_actions::ActionContext, + preview: strand_core::user_actions::ActionPreview, + op_id: String, + on_started: Channel<()>, + state: State<'_, AppState>, +) -> CmdResult { + let cancel = ai::bin::AiCancelHandle::new(); + { + let mut ops = state.ops.lock().map_err(|_| CmdError { message: "operation registry unavailable".into() })?; + if ops.contains_key(&op_id) { return Err(CmdError { message: "Action is already running".into() }); } + ops.insert(op_id.clone(), OperationCancelHandle::Ai(cancel.clone())); + } + // UI replays an early cancellation after this registration handshake. + let _ = on_started.send(()); + let result = run_blocking("user action", move || { + let current = crate::user_actions::preview(&action, &context).map_err(|message| CmdError { message })?; + if current != preview { return Err(CmdError { message: "Action context or executable changed. Preview again before running.".into() }); } + crate::user_actions::run(¤t, &cancel).map_err(|message| CmdError { message }) + }).await; + deregister_op(&state, &Some(op_id)); + result +} + +#[tauri::command(async)] +pub async fn repo_lfs_action( + path: String, + action: LfsAction, + op_id: Option, + on_event: Channel, + state: State<'_, AppState>, +) -> CmdResult { + let cancel = CancelHandle::new(); + register_op(&state, &op_id, OperationCancelHandle::Network(cancel.clone())); + let result = run_blocking("Git LFS", move || { + Repo::discover(&path)?.lfs_action(action, |p| { let _ = on_event.send(p); }, Some(&cancel)).map_err(CmdError::from) + }).await; + deregister_op(&state, &op_id); + result +} + +#[tauri::command(async)] +pub async fn repo_tag_create( path: String, name: String, target: Option, message: Option, force: bool, + signing: Option, ) -> CmdResult<()> { - Repo::discover(&path)?.create_tag(&name, target.as_deref(), message.as_deref(), force)?; - Ok(()) + run_blocking("tag", move || { + Ok(Repo::discover(&path)?.create_tag_with_signing(&name, target.as_deref(), message.as_deref(), force, signing.unwrap_or_default())?) + }).await +} + +#[tauri::command(async)] +pub async fn repo_tag_verify(path: String, name: String) -> CmdResult { + run_blocking("verify-tag", move || Ok(Repo::discover(&path)?.verify_tag(&name)?)).await +} + +#[tauri::command(async)] +pub async fn repo_signing_settings(path: String) -> CmdResult { + run_blocking("signing-settings", move || Ok(Repo::discover(&path)?.signing_settings()?)).await +} + +#[tauri::command(async)] +pub async fn repo_set_signing_config(path: String, scope: SigningScope, key: String, value: Option) -> CmdResult<()> { + run_blocking("signing-settings", move || Ok(Repo::discover(&path)?.set_signing_config(scope, &key, value.as_deref())?)).await } #[tauri::command(async)] @@ -1659,6 +2022,16 @@ pub fn repo_open_in_terminal(path: String, template: String) -> CmdResult<()> { Ok(Repo::discover(&path)?.open_in_terminal(&template)?) } +#[tauri::command(async)] +pub async fn repo_identity(path: String) -> CmdResult { + run_blocking("identity", move || Ok(Repo::discover(&path)?.repository_identity()?)).await +} + +#[tauri::command(async)] +pub async fn repo_set_identity(path: String, field: String, value: Option) -> CmdResult<()> { + run_blocking("identity", move || Ok(Repo::discover(&path)?.set_repository_identity(&field, value.as_deref())?)).await +} + #[tauri::command(async)] pub fn git_global_identity() -> CmdResult { Ok(gitconfig::global_identity()?) @@ -2148,3 +2521,32 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } } + +#[tauri::command(async)] +pub async fn repo_hosting_providers(path: String) -> CmdResult> { + run_blocking("remote hosting providers", move || pull_requests::hosting_providers(&path).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn repo_set_hosting_provider(path: String, remote: String, provider: String) -> CmdResult<()> { + run_blocking("configure remote provider", move || pull_requests::set_hosting_provider(&path, &remote, &provider).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_accounts(path: String, provider: String, host: String) -> CmdResult { + run_blocking("publish destinations", move || pull_requests::publish::accounts(&path, &provider, &host).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_state(path: String) -> CmdResult> { + run_blocking("publish recovery", move || pull_requests::publish::state(&path).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_preview(path: String, request: pull_requests::publish::PublishRequest) -> CmdResult { + run_blocking("review publish destination", move || pull_requests::publish::preview(&path, request).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_advance(path: String, id: String, action: String) -> CmdResult { + run_blocking("publish repository", move || pull_requests::publish::advance(&path, &id, &action).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_forget(path: String) -> CmdResult<()> { + run_blocking("dismiss publish recovery", move || pull_requests::publish::forget(&path).map_err(|message| CmdError { message })).await +} diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0b3fad89..c0ef4101 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -10,6 +10,7 @@ mod path_env; mod pull_requests; mod state; mod terminal; +mod user_actions; use tauri::Manager; @@ -177,6 +178,13 @@ fn main() { commands::repo_refs, commands::azdo_helper_status, commands::hosting_connection_status, + commands::repo_hosting_providers, + commands::repo_set_hosting_provider, + commands::hosted_publish_accounts, + commands::hosted_publish_state, + commands::hosted_publish_preview, + commands::hosted_publish_advance, + commands::hosted_publish_forget, commands::azdo_helper_enable, commands::azdo_helper_disable, commands::azdo_helper_remove, @@ -187,6 +195,9 @@ fn main() { commands::azdo_profile_clear_pat, commands::azdo_profile_test, commands::repo_pull_requests, + commands::repo_pull_request_inbox_page, + commands::repo_pull_request_data_page, + commands::repo_pull_request_cancel_read, commands::repo_pull_request_for_branch, commands::repo_pull_request_create, commands::repo_pull_request_activity, @@ -200,6 +211,12 @@ fn main() { commands::repo_pull_request_thread_reply, commands::repo_pull_request_thread_resolve, commands::repo_pull_request_merge, + commands::repo_pull_request_completion, + commands::repo_pull_request_boundaries, + commands::repo_pull_request_compare_review, + commands::repo_pull_request_feedback, + commands::repo_pull_request_suggestion_preview, + commands::repo_pull_request_suggestion_apply, commands::repo_pull_request_ready, commands::repo_pull_request_lifecycle, commands::repo_pull_request_update_branch, @@ -238,6 +255,11 @@ fn main() { commands::repo_branch_fetch, commands::repo_branch_pull, commands::repo_clone, + commands::repo_clone_scope, + commands::repo_sparse_checkout, + commands::repo_set_sparse_checkout, + commands::repo_disable_sparse_checkout, + commands::repo_expand_history, commands::repo_checkout, commands::repo_checkout_commit, commands::repo_tree, @@ -245,6 +267,8 @@ fn main() { commands::repo_tree_at, commands::repo_submodules, commands::repo_submodule_update, + commands::repo_submodule_children, + commands::repo_submodule_action, commands::repo_worktrees, commands::repo_worktree_add, commands::repo_worktree_remove, @@ -274,6 +298,34 @@ fn main() { commands::repo_remote_set_urls, commands::repo_remote_set_default, commands::repo_maintenance, + commands::repo_gitflow_detect, + commands::repo_gitflow_state, + commands::repo_gitflow_configure, + commands::repo_gitflow_plan, + commands::repo_gitflow_run, + commands::repo_advanced_refs, + commands::repo_git_note, + commands::repo_git_note_write, + commands::repo_replace_review, + commands::repo_replace_write, + commands::repo_tag_edit_review, + commands::repo_tag_edit, + commands::repo_tag_published, + commands::repo_bisect_state, + commands::repo_bisect_start, + commands::repo_bisect_action, + commands::repo_patch_preview, + commands::repo_patch_import, + commands::repo_mailbox_state, + commands::repo_mailbox_action, + commands::repo_bundle_preview, + commands::repo_bundle_import, + commands::repo_bundle_export, + + commands::repo_user_action_preview, + commands::repo_user_action_run, + + commands::repo_lfs_action, commands::repo_tag_create, commands::repo_tag_delete, commands::repo_tag_push, @@ -293,6 +345,11 @@ fn main() { commands::repo_open_mergetool, commands::repo_open_in_editor, commands::repo_open_in_terminal, + commands::repo_tag_verify, + commands::repo_signing_settings, + commands::repo_set_signing_config, + commands::repo_identity, + commands::repo_set_identity, commands::git_global_identity, commands::git_set_global_identity, commands::workspace_file_read, @@ -369,6 +426,7 @@ fn main() { tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } ) { app.state::().terminals.close_all(None); + user_actions::shutdown(); } }); } diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index 9f08edc7..d2291529 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -1,9 +1,13 @@ //! Pull-request host integration. //! -//! Authentication stays with the provider CLIs (`gh` and `az`): Strand never -//! reads or stores their tokens. The list call stays shallow; a second command +//! CLI authentication stays with `gh`, `glab` and `az`; Bitbucket API credentials +//! come from the system Git helper. The list call stays shallow; a second command //! loads nested metadata only for the selected pull request so provider query //! limits and large repositories remain predictable. +mod hosted; +pub(crate) mod publish; +pub(crate) mod transport; +use transport::{github_command, github_command_input, GitHubContext}; use std::{ collections::HashMap, @@ -26,6 +30,10 @@ use uuid::Uuid; use crate::ai::bin::{base_command, resolve_cli}; use crate::azdo_helper; +pub mod pages; +pub mod completion; +pub mod evolution; + const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); const MAX_COMMENT_BYTES: usize = 65_536; const MAX_THREAD_ID_BYTES: usize = 512; @@ -43,8 +51,8 @@ const GITHUB_LIST_FIELDS: &str = concat!( ); const GITHUB_DETAIL_FIELDS: &str = concat!( "number,title,state,isDraft,author,headRefName,baseRefName,createdAt,updatedAt,", - "closedAt,mergedAt,url,body,mergeStateStatus,reviewDecision,comments,commits,additions,deletions,", - "changedFiles,reviewRequests,latestReviews,labels,statusCheckRollup,headRefOid" + "closedAt,mergedAt,url,body,mergeStateStatus,reviewDecision,additions,deletions,", + "changedFiles,reviewRequests,labels,headRefOid" ); const GITHUB_ACTIVITY_QUERY: &str = r#"query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { @@ -57,6 +65,7 @@ const GITHUB_ACTIVITY_QUERY: &str = r#"query($owner: String!, $repo: String!, $n } statusCheckRollup { contexts(first: 100) { + pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { databaseId name status conclusion } @@ -67,44 +76,6 @@ const GITHUB_ACTIVITY_QUERY: &str = r#"query($owner: String!, $repo: String!, $n } } }"#; -const GITHUB_REVIEW_THREADS_QUERY: &str = r#"query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - viewerCanUpdate - reviews(last: 100) { - nodes { - id - body - state - submittedAt - url - viewerCanUpdate - viewerDidAuthor - author { login avatarUrl } - } - } - reviewThreads(first: 100) { - nodes { - id - isResolved - isOutdated - viewerCanReply - viewerCanResolve - viewerCanUnresolve - path - line - startLine - originalLine - originalStartLine - diffSide - comments(first: 100) { - nodes { id body createdAt url author { login avatarUrl } } - } - } - } - } - } -}"#; const GITHUB_REVIEW_UPDATE_MUTATION: &str = r#"mutation($reviewId: ID!, $body: String!) { updatePullRequestReview(input: { pullRequestReviewId: $reviewId, body: $body }) { pullRequestReview { id } @@ -138,6 +109,8 @@ type Result = std::result::Result; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum PullRequestProvider { + GitLab, + Bitbucket, GitHub, AzureDevOps, } @@ -197,6 +170,7 @@ pub struct PullRequestReviewer { #[derive(Debug, Clone, Serialize)] pub struct PullRequestReview { + pub source_commit: Option, pub id: String, pub author: String, pub avatar_url: Option, @@ -210,6 +184,7 @@ pub struct PullRequestReview { #[derive(Debug, Clone, Serialize)] pub struct PullRequestCheck { + pub id: String, pub name: String, pub status: String, } @@ -238,6 +213,8 @@ pub struct PullRequestComment { #[derive(Debug, Clone, Serialize)] pub struct PullRequestReviewThread { + pub suggestion_range_valid: bool, + pub iteration_id: Option, pub id: String, pub path: String, pub start_line: u32, @@ -267,8 +244,23 @@ struct AzureDiscussion { review_threads: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Default)] +pub struct PullRequestCapabilities { + pub can_comment: bool, + pub can_review: bool, + pub can_request_changes: bool, + pub can_close: bool, + pub can_reopen: bool, + pub merge_strategies: Vec, +} + +#[derive(Debug, Clone, Serialize, Default)] pub struct PullRequest { + pub completion: Option, + pub data_pages: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option, pub id: u64, pub title: String, pub state: String, @@ -303,6 +295,8 @@ pub struct PullRequest { #[derive(Debug, Clone, Serialize)] pub struct PullRequestList { + pub next_cursor: Option, + pub total_count: Option, pub repository: PullRequestRepository, pub pull_requests: Vec, } @@ -372,7 +366,9 @@ pub enum PullRequestDiffSide { #[derive(Debug, Clone, PartialEq, Eq)] enum HostRepo { + Hosted(hosted::HostedRepo), GitHub { + host: String, owner: String, repo: String, }, @@ -392,7 +388,8 @@ enum HostRepo { pub fn list(path: &str) -> Result { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => list_github(path, remote, owner, repo), + HostRepo::Hosted(host) => host.list(&host.client(path), remote, None), + HostRepo::GitHub { host, owner, repo } => list_github(&GitHubContext { path, host: &host }, remote, owner, repo), HostRepo::Azure { organization, project, @@ -410,7 +407,8 @@ pub fn list(path: &str) -> Result { pub fn for_branch(path: &str, branch: &str) -> Result> { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => for_branch_github(path, remote, owner, repo, branch), + HostRepo::Hosted(host) => host.list(&host.client(path), remote, Some(branch)).map(|list| list.pull_requests.into_iter().next().map(|pull_request| PullRequestBranchMatch { repository: list.repository, pull_request })), + HostRepo::GitHub { host, owner, repo } => for_branch_github(&GitHubContext { path, host: &host }, remote, owner, repo, branch), HostRepo::Azure { organization, project, @@ -437,8 +435,8 @@ pub fn create( let (remote, host) = host_for_path(path)?; ensure_source_branch_on_remote(path, &remote, source_branch)?; match host { - HostRepo::GitHub { owner, repo } => create_github( - path, + HostRepo::Hosted(host) => host.create(&host.client(path), source_branch, target_branch, title, description, is_draft), + HostRepo::GitHub { host, owner, repo } => create_github(&GitHubContext { path, host: &host }, &owner, &repo, source_branch, @@ -516,7 +514,8 @@ fn ensure_source_branch_on_remote(path: &str, remote: &str, source_branch: &str) pub fn activity(path: &str, id: u64) -> Result { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => activity_github(path, remote, owner, repo, id), + HostRepo::Hosted(host) => host.activity(&host.client(path), remote, id), + HostRepo::GitHub { host, owner, repo } => activity_github(&GitHubContext { path, host: &host }, remote, owner, repo, id), HostRepo::Azure { organization, project, @@ -534,7 +533,8 @@ pub fn activity(path: &str, id: u64) -> Result { pub fn detail(path: &str, id: u64) -> Result { let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => detail_github(path, owner, repo, id), + HostRepo::Hosted(host) => host.detail(&host.client(path), id), + HostRepo::GitHub { host, owner, repo } => detail_github(&GitHubContext { path, host: &host }, owner, repo, id), HostRepo::Azure { organization, project, @@ -552,7 +552,8 @@ pub fn detail(path: &str, id: u64) -> Result { pub fn diff(path: &str, id: u64) -> Result { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => diff_github(path, owner, repo, id), + HostRepo::Hosted(host) => host.diff(&host.client(path), id), + HostRepo::GitHub { host, owner, repo } => diff_github(&GitHubContext { path, host: &host }, owner, repo, id), HostRepo::Azure { organization, project, @@ -571,7 +572,8 @@ pub fn add_comment(path: &str, id: u64, body: &str) -> Result<()> { validate_comment(body)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => add_comment_github(path, owner, repo, id, body), + HostRepo::Hosted(host) => host.add_comment(&host.client(path), id, body), + HostRepo::GitHub { host, owner, repo } => add_comment_github(&GitHubContext { path, host: &host }, owner, repo, id, body), HostRepo::Azure { organization, project, @@ -607,13 +609,13 @@ pub fn add_inline_comment( } let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - let current = detail_github(path, owner.clone(), repo.clone(), id)?; + HostRepo::Hosted(host) => host.inline(&host.client(path), id, &PullRequestPendingComment { path: file_path.into(), start_line, end_line, side, body: body.into() }, expected_head), + HostRepo::GitHub { host, owner, repo } => { + let current = detail_github(&GitHubContext { path, host: &host }, owner.clone(), repo.clone(), id)?; if current.source_commit != expected_head { return Err("The pull request changed while this comment was being written. Refresh Changes and select the lines again.".to_string()); } - add_inline_comment_github( - path, owner, repo, id, body, file_path, start_line, end_line, side, + add_inline_comment_github(&GitHubContext { path, host: &host }, owner, repo, id, body, file_path, start_line, end_line, side, expected_head, ) } @@ -635,7 +637,8 @@ pub fn reply_to_thread(path: &str, thread_id: &str, body: &str) -> Result reply_to_thread_github(path, thread_id, body), + HostRepo::Hosted(host) => host.reply(&host.client(path), thread_id, body), + HostRepo::GitHub { host, .. } => reply_to_thread_github(&GitHubContext { path, host: &host }, thread_id, body), HostRepo::Azure { organization, project, @@ -665,7 +668,8 @@ pub fn set_thread_resolved( validate_thread_id(thread_id)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { .. } => set_thread_resolved_github(path, thread_id, resolved), + HostRepo::Hosted(host) => host.resolve(&host.client(path), thread_id, resolved), + HostRepo::GitHub { host, .. } => set_thread_resolved_github(&GitHubContext { path, host: &host }, thread_id, resolved), HostRepo::Azure { organization, project, @@ -705,8 +709,8 @@ pub fn submit_review( validate_commit(expected_head)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => submit_review_github( - path, &owner, &repo, id, event, body, comments, expected_head, + HostRepo::Hosted(host) => host.review(&host.client(path), id, event, body, comments, expected_head), + HostRepo::GitHub { host, owner, repo } => submit_review_github(&GitHubContext { path, host: &host }, &owner, &repo, id, event, body, comments, expected_head, ), HostRepo::Azure { organization, project, repo } => submit_review_azure( path, &organization, &project, &repo, id, event, body, comments, expected_head, @@ -722,7 +726,8 @@ pub fn update_review(path: &str, _id: u64, review_id: &str, body: &str) -> Resul validate_comment(body)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { .. } => update_review_github(path, review_id, body), + HostRepo::Hosted(_) => Err("Edit review summaries on the provider website".into()), + HostRepo::GitHub { host, .. } => update_review_github(&GitHubContext { path, host: &host }, review_id, body), HostRepo::Azure { .. } | HostRepo::AzureServer { .. } => Err( "Azure DevOps votes do not have an editable review summary. Submit a new summary comment or change the vote instead." .into(), @@ -739,9 +744,10 @@ pub fn dismiss_review( validate_review_id(review_id)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { .. } => { + HostRepo::Hosted(_) => Err("Reset reviews on the provider website".into()), + HostRepo::GitHub { host, .. } => { validate_comment(message)?; - dismiss_review_github(path, review_id, message) + dismiss_review_github(&GitHubContext { path, host: &host }, review_id, message) } HostRepo::Azure { organization, .. } => { reset_review_azure(path, &organization, id, review_id) @@ -764,8 +770,9 @@ pub fn merge( validate_commit(expected_head)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - merge_github(path, &owner, &repo, id, strategy, expected_head) + HostRepo::Hosted(host) => host.merge(&host.client(path), id, strategy, expected_head), + HostRepo::GitHub { host, owner, repo } => { + merge_github(&GitHubContext { path, host: &host }, &owner, &repo, id, strategy, expected_head) } HostRepo::Azure { organization, @@ -792,7 +799,8 @@ pub fn merge( pub fn mark_ready(path: &str, id: u64) -> Result<()> { let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => mark_ready_github(path, &owner, &repo, id), + HostRepo::Hosted(host) => host.ready(&host.client(path), id), + HostRepo::GitHub { host, owner, repo } => mark_ready_github(&GitHubContext { path, host: &host }, &owner, &repo, id), HostRepo::Azure { organization, .. } => mark_ready_azure(path, &organization, id), HostRepo::AzureServer { profile_id, @@ -806,8 +814,9 @@ pub fn mark_ready(path: &str, id: u64) -> Result<()> { pub fn set_lifecycle(path: &str, id: u64, action: PullRequestLifecycleAction) -> Result<()> { let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - set_lifecycle_github(path, &owner, &repo, id, action) + HostRepo::Hosted(host) => host.lifecycle(&host.client(path), id, action), + HostRepo::GitHub { host, owner, repo } => { + set_lifecycle_github(&GitHubContext { path, host: &host }, &owner, &repo, id, action) } HostRepo::Azure { organization, .. } => { set_lifecycle_azure(path, &organization, id, action) @@ -825,8 +834,9 @@ pub fn update_branch(path: &str, id: u64, expected_head: &str) -> Result<()> { validate_commit(expected_head)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - update_branch_github(path, &owner, &repo, id, expected_head) + HostRepo::Hosted(_) => Err("Update this source branch in a local worktree".into()), + HostRepo::GitHub { host, owner, repo } => { + update_branch_github(&GitHubContext { path, host: &host }, &owner, &repo, id, expected_head) } HostRepo::Azure { .. } | HostRepo::AzureServer { .. } => Err( "Azure DevOps does not expose a safe update-source-branch pull-request operation. Open the branch in a worktree and update it locally." @@ -843,8 +853,9 @@ pub fn prepare_checkout( validate_commit(expected_head)?; let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - prepare_checkout_github(path, &remote, &owner, &repo, id, expected_head) + HostRepo::Hosted(host) => host.checkout(&host.client(path), path, &remote, id, expected_head), + HostRepo::GitHub { host, owner, repo } => { + prepare_checkout_github(&GitHubContext { path, host: &host }, &remote, &owner, &repo, id, expected_head) } HostRepo::Azure { organization, .. } => { let value = azure_pr_value(path, &organization, id)?; @@ -881,7 +892,10 @@ fn host_for_path(path: &str) -> Result<(String, HostRepo)> { let mut supported = remotes .iter() .filter_map(|remote| { - let coordinates = parse_remote(&remote.1)?; + let coordinates = parse_remote(&remote.1).or_else(|| { + let configured = run_command(path, "git", &["config", "--get", &format!("remote.{}.strand-provider", remote.0)], &[]).ok().and_then(|v| String::from_utf8(v).ok()); + parse_hosted_remote(&remote.1, configured.as_deref().map(str::trim)) + })?; Some((remote.0.clone(), coordinates)) }) .collect::>(); @@ -906,66 +920,25 @@ fn host_for_path(path: &str) -> Result<(String, HostRepo)> { } supported.into_iter().next().ok_or_else(|| { - "No supported GitHub, Azure DevOps Services, or configured Azure DevOps Server remote was found for this repository".to_string() + "No supported hosting remote was found. Configure a custom GitHub/GitLab remote provider in Hosting settings.".to_string() }) } -fn list_github(cwd: &str, remote: String, owner: String, repo: String) -> Result { - let slug = format!("{owner}/{repo}"); - // Keep the list query shallow. Asking GraphQL to expand nested comments, - // commits, reviews, and checks across 100 PRs can exceed GitHub's 500k - // possible-node cap even for a modest repository. Rich fields load only - // for the selected PR via `detail_github`. - let (output, viewer) = thread::scope(|scope| { - let viewer = scope.spawn(|| github_viewer(cwd)); - let output = run_command( - cwd, - "gh", - &[ - "pr", - "list", - "--repo", - &slug, - "--state", - "all", - "--limit", - "100", - "--json", - GITHUB_LIST_FIELDS, - ], - &[("GH_PROMPT_DISABLED", "1")], - ); - let viewer = viewer.join().ok().and_then(Result::ok); - (output, viewer) - }); - let output = output?; - let values: Vec = serde_json::from_slice(&output) - .map_err(|e| format!("GitHub CLI returned invalid JSON: {e}"))?; - let pull_requests = values - .iter() - .filter_map(|value| parse_github_pr(value, viewer.as_deref())) - .collect(); - Ok(PullRequestList { - repository: PullRequestRepository { - provider: PullRequestProvider::GitHub, - remote, - label: slug, - viewer, - }, - pull_requests, - }) +fn list_github(cwd: &GitHubContext<'_>, remote: String, owner: String, repo: String) -> Result { + let _ = (remote, owner, repo); + pages::inbox(cwd.path, None, &Uuid::new_v4().to_string()) } fn for_branch_github( - cwd: &str, + cwd: &GitHubContext<'_>, remote: String, owner: String, repo: String, branch: &str, ) -> Result> { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(&owner, &repo); let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1000,19 +973,9 @@ fn for_branch_github( })) } -fn github_viewer(cwd: &str) -> Result { - let output = run_command( - cwd, - "gh", - &["api", "user", "--jq", ".login"], - &[("GH_PROMPT_DISABLED", "1")], - )?; - non_empty_text(&output, "GitHub CLI returned no signed-in account") -} - #[allow(clippy::too_many_arguments)] fn create_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, source_branch: &str, @@ -1021,7 +984,7 @@ fn create_github( description: &str, is_draft: bool, ) -> Result { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(owner, repo); let source_branch = branch_name(source_branch.to_string()); let target_branch = branch_name(target_branch.to_string()); let mut args = vec![ @@ -1041,7 +1004,7 @@ fn create_github( if is_draft { args.push("--draft"); } - let output = run_command_input( + let output = github_command_input( cwd, "gh", &args, @@ -1084,7 +1047,7 @@ fn map_github_create_error(error: String, source_branch: &str, target_branch: &s } fn activity_github( - cwd: &str, + cwd: &GitHubContext<'_>, remote: String, owner: String, repo: String, @@ -1094,7 +1057,7 @@ fn activity_github( let owner_arg = format!("owner={owner}"); let repo_arg = format!("repo={repo}"); let number = format!("number={id}"); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1107,21 +1070,23 @@ fn activity_github( let pull_request = value .pointer("/data/repository/pullRequest") .ok_or_else(|| format!("GitHub returned no activity data for PR #{id}"))?; + let mut pull_request = pull_request.clone(); + pages::activity_checks(cwd, &owner, &repo, id, &mut pull_request)?; parse_github_activity( - pull_request, + &pull_request, PullRequestRepository { provider: PullRequestProvider::GitHub, remote, - label: format!("{owner}/{repo}"), + label: cwd.slug(&owner, &repo), viewer: None, }, ) } -fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result { - let slug = format!("{owner}/{repo}"); +fn detail_github(cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64) -> Result { + let slug = cwd.slug(&owner, &repo); let id_string = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1139,61 +1104,29 @@ fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result| cwd.scope_avatar(avatar); + for comment in &mut pull_request.comments { scope(&mut comment.avatar_url); } + for commit in &mut pull_request.commits { scope(&mut commit.avatar_url); } + for review in &mut pull_request.reviews { scope(&mut review.avatar_url); } + for thread in &mut pull_request.review_threads { + for comment in &mut thread.comments { scope(&mut comment.avatar_url); } + } + } Ok(pull_request) } -fn github_review_threads( - cwd: &str, - owner: &str, - repo: &str, - id: u64, -) -> Result<(Vec, Vec, bool)> { - let query = format!("query={GITHUB_REVIEW_THREADS_QUERY}"); - let owner = format!("owner={owner}"); - let repo = format!("repo={repo}"); - let number = format!("number={id}"); - let output = run_command( - cwd, - "gh", - &[ - "api", "graphql", "-f", &query, "-F", &owner, "-F", &repo, "-F", &number, - ], - &[("GH_PROMPT_DISABLED", "1")], - )?; - let value: Value = serde_json::from_slice(&output) - .map_err(|error| format!("GitHub CLI returned invalid review-thread JSON: {error}"))?; - Ok(( - parse_github_review_threads(&value), - parse_github_reviews(&value), - parse_github_can_mark_ready(&value), - )) -} - -fn diff_github(cwd: &str, owner: String, repo: String, id: u64) -> Result { - let slug = format!("{owner}/{repo}"); +fn diff_github(cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64) -> Result { + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &["pr", "diff", &id, "--repo", &slug, "--color", "never"], @@ -1206,10 +1139,10 @@ fn diff_github(cwd: &str, owner: String, repo: String, id: u64) -> Result Result<()> { - let slug = format!("{owner}/{repo}"); +fn add_comment_github(cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64, body: &str) -> Result<()> { + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - run_command_input( + github_command_input( cwd, "gh", &["pr", "comment", &id, "--repo", &slug, "--body-file", "-"], @@ -1221,7 +1154,7 @@ fn add_comment_github(cwd: &str, owner: String, repo: String, id: u64, body: &st #[allow(clippy::too_many_arguments)] fn add_inline_comment_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64, @@ -1237,7 +1170,7 @@ fn add_inline_comment_github( github_inline_comment_payload(body, file_path, start_line, end_line, side, expected_head); let input = serde_json::to_vec(&payload) .map_err(|error| format!("Could not encode GitHub inline comment: {error}"))?; - run_command_input( + github_command_input( cwd, "gh", &["api", "--method", "POST", &endpoint, "--input", "-"], @@ -1249,7 +1182,7 @@ fn add_inline_comment_github( #[allow(clippy::too_many_arguments)] fn submit_review_github( - cwd: &str, owner: &str, repo: &str, id: u64, event: PullRequestReviewEvent, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, event: PullRequestReviewEvent, body: &str, comments: &[PullRequestPendingComment], expected_head: &str, ) -> Result<()> { let current = github_current_head(cwd, owner, repo, id)?; @@ -1257,17 +1190,17 @@ fn submit_review_github( let endpoint = format!("repos/{owner}/{repo}/pulls/{id}/reviews"); let input = serde_json::to_vec(&github_review_payload(event, body, comments, expected_head)) .map_err(|error| format!("Could not encode GitHub review: {error}"))?; - run_command_input( + github_command_input( cwd, "gh", &["api", "--method", "POST", &endpoint, "--input", "-"], &[("GH_PROMPT_DISABLED", "1")], Some(&input), )?; Ok(()) } -fn github_current_head(cwd: &str, owner: &str, repo: &str, id: u64) -> Result { - let slug = format!("{owner}/{repo}"); +fn github_current_head(cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64) -> Result { + let slug = cwd.slug(owner, repo); let id = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &["pr", "view", &id, "--repo", &slug, "--json", "headRefOid"], &[("GH_PROMPT_DISABLED", "1")], )?; @@ -1278,7 +1211,7 @@ fn github_current_head(cwd: &str, owner: &str, repo: &str, id: u64) -> Result, owner: &str, repo: &str, id: u64, @@ -1287,7 +1220,7 @@ fn update_branch_github( let endpoint = format!("repos/{owner}/{repo}/pulls/{id}/update-branch"); let input = serde_json::to_vec(&github_update_branch_payload(expected_head)) .map_err(|error| format!("Could not encode GitHub branch update: {error}"))?; - run_command_input( + github_command_input( cwd, "gh", &["api", "--method", "PUT", &endpoint, "--input", "-"], @@ -1298,16 +1231,16 @@ fn update_branch_github( } fn prepare_checkout_github( - cwd: &str, + cwd: &GitHubContext<'_>, remote: &str, owner: &str, repo: &str, id: u64, expected_head: &str, ) -> Result { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(owner, repo); let number = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1331,7 +1264,7 @@ fn prepare_checkout_github( .filter(|branch| !branch.is_empty()) .ok_or_else(|| "GitHub did not return the pull request source branch".to_string())?; let pull_ref = github_pull_head_ref(id); - Repo::discover(cwd) + Repo::discover(cwd.path) .map_err(|error| error.to_string())? .fetch_refs_for_read(remote, &[&pull_ref]) .map_err(|error| format!("Could not fetch GitHub PR #{id} for a worktree: {error}"))?; @@ -1349,19 +1282,21 @@ fn github_pull_head_ref(id: u64) -> String { format!("refs/pull/{id}/head") } -fn reply_to_thread_github(cwd: &str, thread_id: &str, body: &str) -> Result { +fn reply_to_thread_github(cwd: &GitHubContext<'_>, thread_id: &str, body: &str) -> Result { let value = run_github_graphql_mutation( cwd, GITHUB_THREAD_REPLY_MUTATION, serde_json::json!({ "threadId": thread_id, "body": body }), )?; - parse_github_thread_reply(&value).ok_or_else(|| { + let mut reply = parse_github_thread_reply(&value).ok_or_else(|| { "GitHub accepted the reply request but returned an incomplete comment".to_string() - }) + })?; + cwd.scope_avatar(&mut reply.avatar_url); + Ok(reply) } fn set_thread_resolved_github( - cwd: &str, + cwd: &GitHubContext<'_>, thread_id: &str, resolved: bool, ) -> Result { @@ -1377,7 +1312,7 @@ fn set_thread_resolved_github( }) } -fn update_review_github(cwd: &str, review_id: &str, body: &str) -> Result<()> { +fn update_review_github(cwd: &GitHubContext<'_>, review_id: &str, body: &str) -> Result<()> { run_github_graphql_mutation( cwd, GITHUB_REVIEW_UPDATE_MUTATION, @@ -1386,7 +1321,7 @@ fn update_review_github(cwd: &str, review_id: &str, body: &str) -> Result<()> { Ok(()) } -fn dismiss_review_github(cwd: &str, review_id: &str, message: &str) -> Result<()> { +fn dismiss_review_github(cwd: &GitHubContext<'_>, review_id: &str, message: &str) -> Result<()> { run_github_graphql_mutation( cwd, GITHUB_REVIEW_DISMISS_MUTATION, @@ -1399,10 +1334,10 @@ fn github_graphql_payload(query: &str, variables: Value) -> Value { serde_json::json!({ "query": query, "variables": variables }) } -fn run_github_graphql_mutation(cwd: &str, query: &str, variables: Value) -> Result { +fn run_github_graphql_mutation(cwd: &GitHubContext<'_>, query: &str, variables: Value) -> Result { let input = serde_json::to_vec(&github_graphql_payload(query, variables)) .map_err(|error| format!("Could not encode GitHub review request: {error}"))?; - let output = run_command_input( + let output = github_command_input( cwd, "gh", &["api", "graphql", "--method", "POST", "--input", "-"], @@ -1475,37 +1410,27 @@ fn github_review_payload( } fn merge_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, strategy: PullRequestMergeStrategy, expected_head: &str, ) -> Result<()> { - let slug = format!("{owner}/{repo}"); - let id = id.to_string(); - run_command( - cwd, - "gh", - &[ - "pr", - "merge", - &id, - "--repo", - &slug, - github_merge_flag(strategy), - "--match-head-commit", - expected_head, - ], - &[("GH_PROMPT_DISABLED", "1")], - )?; + let endpoint = format!("repos/{owner}/{repo}/pulls/{id}/merge"); + let input = serde_json::to_vec(&completion::github_merge_payload(strategy, expected_head)).map_err(|e| e.to_string())?; + let output = github_command_input(cwd, "gh", &["api", &endpoint, "--method", "PUT", "--input", "-"], &[("GH_PROMPT_DISABLED", "1")], Some(&input))?; + let value: Value = serde_json::from_slice(&output).map_err(|e| e.to_string())?; + if value["merged"].as_bool() != Some(true) { + return Err(text(value.get("message")).unwrap_or_else(|| "GitHub did not merge the pull request".into())); + } Ok(()) } -fn mark_ready_github(cwd: &str, owner: &str, repo: &str, id: u64) -> Result<()> { - let slug = format!("{owner}/{repo}"); +fn mark_ready_github(cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64) -> Result<()> { + let slug = cwd.slug(owner, repo); let id = id.to_string(); - run_command( + github_command( cwd, "gh", &["pr", "ready", &id, "--repo", &slug], @@ -1515,16 +1440,16 @@ fn mark_ready_github(cwd: &str, owner: &str, repo: &str, id: u64) -> Result<()> } fn set_lifecycle_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, action: PullRequestLifecycleAction, ) -> Result<()> { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(owner, repo); let id = id.to_string(); let verb = github_lifecycle_verb(action); - run_command( + github_command( cwd, "gh", &["pr", verb, &id, "--repo", &slug], @@ -1579,6 +1504,8 @@ fn list_azure( }) .collect(); Ok(PullRequestList { + next_cursor: None, + total_count: None, repository: PullRequestRepository { provider: PullRequestProvider::AzureDevOps, remote, @@ -1624,6 +1551,8 @@ fn list_azure_server( }) .collect(); Ok(PullRequestList { + next_cursor: None, + total_count: None, repository: PullRequestRepository { provider: PullRequestProvider::AzureDevOps, remote, @@ -1745,12 +1674,14 @@ fn detail_azure_server( pull_request.checks = checks .iter() .map(|check| PullRequestCheck { + id: check.id.clone(), name: check.name.clone(), status: check.status.clone(), }) .collect(); pull_request.checks_complete = true; } + pull_request.completion = Some(completion::azure(&value, viewer.as_deref())); Ok(pull_request) } @@ -2418,12 +2349,14 @@ fn detail_azure( pull_request.checks = checks .iter() .map(|check| PullRequestCheck { + id: check.id.clone(), name: check.name.clone(), status: check.status.clone(), }) .collect(); pull_request.checks_complete = true; } + pull_request.completion = Some(completion::azure(&value, viewer.as_deref())); Ok(pull_request) } @@ -3343,6 +3276,20 @@ fn run_command_input( envs: &[(&str, &str)], stdin_data: Option<&[u8]>, ) -> Result> { + run_command_input_cancellable(cwd, program, args, envs, stdin_data, None) +} + +fn run_command_input_cancellable( + cwd: &str, + program: &str, + args: &[&str], + envs: &[(&str, &str)], + stdin_data: Option<&[u8]>, + cancelled: Option<&std::sync::atomic::AtomicBool>, +) -> Result> { + if cancelled.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) { + return Err("Read cancelled".into()); + } // Resolve strictly through PATH before setting the untrusted repository as // cwd. On Windows, CreateProcess otherwise searches cwd and could execute // a repository-owned `gh.exe`/`az.exe`. Reuse the AI CLI resolver so batch @@ -3414,7 +3361,7 @@ fn run_command_input( return Err(format!("{program} wait failed: {error}")); } } - if started.elapsed() >= COMMAND_TIMEOUT { + if started.elapsed() >= COMMAND_TIMEOUT || cancelled.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) { let _ = child.kill(); let _ = child.wait(); let _ = stdout_reader.join(); @@ -3422,7 +3369,7 @@ fn run_command_input( if let Some(writer) = stdin_writer.take() { let _ = writer.join(); } - return Err(format!("{program} timed out after 30 seconds")); + return Err(if cancelled.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) { "Read cancelled".into() } else { format!("{program} timed out after 30 seconds") }); } thread::sleep(Duration::from_millis(25)); }; @@ -3467,6 +3414,8 @@ fn auth_hint(program: &str, stderr: &str) -> &'static str { "" } else if program == "gh" { " Sign in with `gh auth login`, then try again." + } else if program == "glab" { + " Sign in with `glab auth login --hostname HOST` for this remote, then try again." } else { " Sign in with `az login`, then try again." } @@ -3666,13 +3615,6 @@ fn validate_commit(commit: &str) -> Result<()> { Ok(()) } -fn github_merge_flag(strategy: PullRequestMergeStrategy) -> &'static str { - match strategy { - PullRequestMergeStrategy::MergeCommit => "--merge", - PullRequestMergeStrategy::Squash => "--squash", - PullRequestMergeStrategy::Rebase => "--rebase", - } -} fn github_lifecycle_verb(action: PullRequestLifecycleAction) -> &'static str { match action { @@ -3916,7 +3858,7 @@ fn parse_github_activity( comments, reviews, checks, - checks_complete: true, + checks_complete: value.pointer("/statusCheckRollup/contexts/pageInfo/hasNextPage").and_then(Value::as_bool) == Some(false) || value.get("statusCheckRollup") == Some(&Value::Null), }) } @@ -4042,6 +3984,7 @@ fn parse_github_pr(value: &Value, viewer: Option<&str>) -> Option { .iter() .filter_map(|check| { Some(PullRequestCheck { + id: text(check.get("id")).unwrap_or_default(), name: text(check.get("name")).or_else(|| text(check.get("context")))?, status: text(check.get("conclusion")) .filter(|status| !status.is_empty()) @@ -4051,6 +3994,10 @@ fn parse_github_pr(value: &Value, viewer: Option<&str>) -> Option { }) .collect(); Some(PullRequest { + data_pages: Vec::new(), + completion: None, + + capabilities: None, id, title: text(value.get("title")).unwrap_or_default(), state: text(value.get("state")) @@ -4134,6 +4081,7 @@ fn parse_github_reviews(value: &Value) -> Vec { .filter_map(|review| { let state = text(review.get("state")).unwrap_or_else(|| "unknown".into()); Some(PullRequestReview { + source_commit: text(review.pointer("/commit/oid")), id: text(review.get("id"))?, author: text(review.pointer("/author/login")) .unwrap_or_else(|| "unknown".into()), @@ -4165,16 +4113,16 @@ fn parse_github_review_threads(value: &Value) -> Vec { let end_line = thread .get("line") .and_then(Value::as_u64) - .or_else(|| thread.get("originalLine").and_then(Value::as_u64))? + .or_else(|| thread.get("originalLine").and_then(Value::as_u64)).unwrap_or(0) as u32; let start_line = thread - .get("startLine") + .get(if thread.get("line").and_then(Value::as_u64).is_some() { "startLine" } else { "originalStartLine" }) .and_then(Value::as_u64) - .or_else(|| thread.get("originalStartLine").and_then(Value::as_u64)) .unwrap_or(u64::from(end_line)) as u32; - let side = match text(thread.get("diffSide"))?.as_str() { + let side = match text(thread.get("diffSide")).unwrap_or_default().as_str() { "LEFT" => PullRequestDiffSide::Deletions, "RIGHT" => PullRequestDiffSide::Additions, + _ if end_line == 0 => PullRequestDiffSide::Additions, _ => return None, }; let comments = thread @@ -4203,6 +4151,9 @@ fn parse_github_review_threads(value: &Value) -> Vec { return None; } Some(PullRequestReviewThread { + suggestion_range_valid: thread.get("line").and_then(Value::as_u64).is_some_and(|n| n > 0) + && (thread.get("startLine").and_then(Value::as_u64).is_none() || text(thread.get("startDiffSide")).as_deref() == Some("RIGHT")), + iteration_id: None, id: text(thread.get("id"))?, path, start_line, @@ -4338,6 +4289,7 @@ fn parse_azure_pr( }; let reviewer_identity = text(reviewer.get("uniqueName")); Some(PullRequestReview { + source_commit: None, id: text(reviewer.get("id"))?, author: text(reviewer.get("displayName")).unwrap_or_else(|| "unknown".into()), avatar_url: text(reviewer.get("imageUrl")), @@ -4370,6 +4322,10 @@ fn parse_azure_pr( "review required".into() }; Some(PullRequest { + data_pages: Vec::new(), + completion: None, + + capabilities: None, id, title: text(value.get("title")).unwrap_or_default(), state: text(value.get("status")) @@ -4536,12 +4492,9 @@ fn parse_azure_review_threads( }) .filter_map(|thread| { let thread_id = thread.get("id").and_then(Value::as_u64)?; - let path = text(thread.pointer("/threadContext/filePath"))? + let path = text(thread.pointer("/threadContext/filePath")).unwrap_or_default() .trim_start_matches('/') .to_string(); - if path.is_empty() { - return None; - } let right_start = thread.pointer("/threadContext/rightFileStart/line").and_then(Value::as_u64); let right_end = thread.pointer("/threadContext/rightFileEnd/line").and_then(Value::as_u64); let left_start = thread.pointer("/threadContext/leftFileStart/line").and_then(Value::as_u64); @@ -4549,9 +4502,10 @@ fn parse_azure_review_threads( let (start_line, end_line, side) = if right_start.is_some() || right_end.is_some() { let end = right_end.or(right_start)?; (right_start.unwrap_or(end), end, PullRequestDiffSide::Additions) - } else { - let end = left_end.or(left_start)?; + } else if let Some(end) = left_end.or(left_start) { (left_start.unwrap_or(end), end, PullRequestDiffSide::Deletions) + } else { + (0, 0, PullRequestDiffSide::Additions) }; let comments = array(thread, "comments") .iter() @@ -4567,6 +4521,9 @@ fn parse_azure_review_threads( .find_map(|comment| comment.get("id").and_then(Value::as_u64))?; let is_resolved = azure_thread_resolved(thread, false); Some(PullRequestReviewThread { + suggestion_range_valid: thread.pointer("/threadContext/rightFileStart/offset").and_then(Value::as_u64) == Some(1) + && thread.pointer("/threadContext/rightFileEnd/offset").and_then(Value::as_u64) == Some(1), + iteration_id: thread.pointer("/pullRequestThreadContext/iterationContext/secondComparingIteration").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()), id: azure_thread_id(pull_request_id, thread_id, parent_comment_id), path, start_line: u32::try_from(start_line).ok()?, @@ -4638,7 +4595,49 @@ fn branch_name(value: String) -> String { .to_string() } +fn parse_hosted_remote(remote: &str, configured: Option<&str>) -> Option { + let remote = if remote.contains("://") { remote.to_string() } + else { let (user_host, path) = remote.split_once(':')?; format!("ssh://{user_host}/{path}") }; + let url = url::Url::parse(&remote).ok()?; + if !matches!(url.scheme(), "https" | "ssh") || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { return None; } + let host = format!("{}{}", url.host_str()?, url.port().map(|p| format!(":{p}")).unwrap_or_default()); + transport::validate_host(&host).ok()?; + let path = url.path().trim_matches('/').trim_end_matches(".git"); + let parts = path.split('/').map(percent_decode).collect::>(); + if parts.len() < 2 || parts.iter().any(|p| p.is_empty() || p == "." || p == ".." || p.contains(['/', '\\', '\0', '\r', '\n'])) { return None; } + let provider = match host.as_str() { "github.com" => "github", "gitlab.com" => "gitlab", "bitbucket.org" => "bitbucket", _ => configured? }; + match provider { + "github" if parts.len() == 2 => Some(HostRepo::GitHub { host, owner: parts[0].clone(), repo: parts[1].clone() }), + "gitlab" | "bitbucket" if provider == "gitlab" || host == "bitbucket.org" && parts.len() == 2 => Some(HostRepo::Hosted(hosted::HostedRepo { + provider: provider.into(), host, namespace: parts[..parts.len()-1].join("/"), repo: parts.last()?.clone(), + })), + _ => None, + } +} + +#[derive(Debug, Serialize)] +pub struct RemoteHostingProvider { pub remote: String, pub url: String, pub provider: String } + +pub fn hosting_providers(path: &str) -> Result> { + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + repo.refs().map_err(|e| e.to_string())?.remotes.into_iter().map(|remote| { + let url = repo.configured_remote_url(&remote.name).map_err(|e| e.to_string())?.unwrap_or_default(); + let provider = run_command(path, "git", &["config", "--get", &format!("remote.{}.strand-provider", remote.name)], &[]).ok().and_then(|v| String::from_utf8(v).ok()).unwrap_or_default().trim().to_string(); + Ok(RemoteHostingProvider { remote: remote.name, url, provider }) + }).collect() +} + +pub fn set_hosting_provider(path: &str, remote: &str, provider: &str) -> Result<()> { + let remotes = hosting_providers(path)?; + let remote = remotes.iter().find(|r| r.remote == remote).ok_or("Remote no longer exists")?; + if !matches!(provider, "" | "github" | "gitlab") { return Err("Select automatic detection, GitHub, or GitLab".into()); } + if !provider.is_empty() && parse_hosted_remote(&remote.url, Some(provider)).is_none() { return Err("This remote has no supported HTTPS/SSH repository coordinates".into()); } + run_command(path, "git", &["config", "--local", &format!("remote.{}.strand-provider", remote.remote), provider], &[])?; + Ok(()) +} + fn parse_remote(url: &str) -> Option { + if let Some(host) = parse_hosted_remote(url, None) { return Some(host); } let trimmed = url.trim().trim_end_matches(".git").trim_end_matches('/'); if let Some(rest) = trimmed .strip_prefix("https://github.com/") @@ -4648,6 +4647,7 @@ fn parse_remote(url: &str) -> Option { { let mut parts = rest.split('/'); return Some(HostRepo::GitHub { + host: "github.com".into(), owner: percent_decode(parts.next()?), repo: percent_decode(parts.next()?), }); @@ -4750,6 +4750,17 @@ mod tests { use super::*; use std::{path::Path, process::Command}; + #[test] + fn custom_hosts_require_explicit_adapter_and_preserve_auth_coordinates() { + assert!(parse_hosted_remote("git@enterprise.example:team/repo.git", None).is_none()); + assert_eq!(parse_hosted_remote("ssh://git@enterprise.example:8443/team/repo.git", Some("github")), Some(HostRepo::GitHub {host:"enterprise.example:8443".into(),owner:"team".into(),repo:"repo".into()})); + assert!(matches!(parse_hosted_remote("https://gitlab.example/group/sub/repo.git",Some("gitlab")), Some(HostRepo::Hosted(host)) if host.namespace == "group/sub" && host.host == "gitlab.example")); + assert!(parse_hosted_remote("https://bitbucket.example/projects/A/repos/b",Some("bitbucket")).is_none()); + assert!(parse_hosted_remote("https://enterprise.example/team/repo/extra",Some("github")).is_none()); + assert!(parse_hosted_remote("https://token:secret@enterprise.example/team/repo",Some("github")).is_none()); + assert!(parse_hosted_remote("https://enterprise.example/team%2Frepo/app",Some("github")).is_none()); + } + fn git(dir: &Path, args: &[&str]) -> String { let output = Command::new("git") .current_dir(dir) @@ -4769,6 +4780,7 @@ mod tests { assert_eq!( parse_remote("git@github.com:openai/codex.git"), Some(HostRepo::GitHub { + host: "github.com".into(), owner: "openai".into(), repo: "codex".into() }) @@ -4801,8 +4813,8 @@ mod tests { #[test] fn rejects_unimplemented_hosts() { - assert_eq!(parse_remote("git@gitlab.com:acme/web.git"), None); - assert_eq!(parse_remote("https://bitbucket.org/acme/web.git"), None); + assert!(matches!(parse_remote("git@gitlab.com:acme/web.git"), Some(HostRepo::Hosted(_)))); + assert!(matches!(parse_remote("https://bitbucket.org/acme/web.git"), Some(HostRepo::Hosted(_)))); } #[test] @@ -5016,7 +5028,7 @@ mod tests { } for nested in ["comments", "commits", "latestReviews", "statusCheckRollup"] { assert!(!GITHUB_LIST_FIELDS.contains(nested)); - assert!(GITHUB_DETAIL_FIELDS.contains(nested)); + assert!(!GITHUB_DETAIL_FIELDS.contains(nested)); } assert_eq!( auth_hint( @@ -5117,7 +5129,7 @@ mod tests { "viewerCanResolve", "viewerCanUnresolve", ] { - assert!(GITHUB_REVIEW_THREADS_QUERY.contains(field)); + assert!(pages::review_query_contract().contains(field)); } } @@ -5140,7 +5152,7 @@ mod tests { "reviewThreads": { "nodes": [{ "comments": { "nodes": [ { "id": "PRRC_1", "author": { "login": "linus" } } ] } }] }, - "statusCheckRollup": { "contexts": { "nodes": [ + "statusCheckRollup": { "contexts": { "pageInfo": {"hasNextPage":false}, "nodes": [ { "__typename": "CheckRun", "databaseId": 99, "name": "CI", "status": "COMPLETED", "conclusion": "FAILURE" }, { "__typename": "StatusContext", "id": "SC_1", "context": "lint", "state": "SUCCESS" } ] } } @@ -5265,7 +5277,9 @@ mod tests { let discussion = parse_azure_discussion(&value, "https://dev.azure.com/acme/pr/7", 7); assert_eq!(discussion.comments.len(), 4); - assert_eq!(discussion.review_threads.len(), 2); + assert_eq!(discussion.review_threads.len(), 3); + assert_eq!(discussion.review_threads[2].path, ""); + assert_eq!(discussion.review_threads[2].end_line, 0); let added = &discussion.review_threads[0]; assert_eq!(added.id, "azure:7:9:1"); assert_eq!(added.path, "src/lib.rs"); @@ -5496,12 +5510,12 @@ mod tests { assert!(validate_commit("0123456").is_err()); assert!(validate_commit("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_err()); assert_eq!( - github_merge_flag(PullRequestMergeStrategy::MergeCommit), - "--merge" + completion::github_merge_payload(PullRequestMergeStrategy::MergeCommit, "head")["merge_method"], + "merge" ); assert_eq!( - github_merge_flag(PullRequestMergeStrategy::Squash), - "--squash" + completion::github_merge_payload(PullRequestMergeStrategy::Squash, "head")["merge_method"], + "squash" ); assert_eq!( azure_merge_strategy(PullRequestMergeStrategy::MergeCommit), diff --git a/crates/strand-tauri/src/pull_requests/completion.rs b/crates/strand-tauri/src/pull_requests/completion.rs new file mode 100644 index 00000000..2ff33348 --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/completion.rs @@ -0,0 +1,331 @@ +//! Provider-specific deferred completion; never infer readiness from loaded checks. +use super::*; + +pub const GITHUB_FIELDS: &str = r#" + id state isDraft headRefOid mergeStateStatus + repository { viewerPermission mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed } + isMergeQueueEnabled isInMergeQueue mergeQueueEntry { position state } + autoMergeRequest { enabledAt } viewerCanEnableAutoMerge viewerCanDisableAutoMerge +"#; + +#[derive(Debug, Clone, Serialize)] +pub struct Completion { + pub kind: String, + pub status: String, + pub source_commit: String, + pub position: Option, + pub can_enable: bool, + pub can_cancel: bool, + pub blockers: Vec, + pub strategies: Vec, +} + +pub fn github(value: &Value) -> Completion { + let queue = value["isMergeQueueEnabled"].as_bool() == Some(true); + let queued = value["isInMergeQueue"].as_bool() == Some(true); + let auto = value.get("autoMergeRequest").is_some_and(|v| !v.is_null()); + let open = value["state"].as_str() == Some("OPEN") && value["isDraft"].as_bool() == Some(false); + let writer = matches!( + value["repository"]["viewerPermission"].as_str(), + Some("WRITE" | "MAINTAIN" | "ADMIN") + ); + let status = match value["state"].as_str() { + Some("MERGED") => "merged", + Some("CLOSED") => "closed", + _ if queued => "queued", + _ if auto => "waiting_for_policies", + _ => "disabled", + }; + let merge_status = value["mergeStateStatus"].as_str().unwrap_or("UNKNOWN"); + Completion { + kind: if queue { + "github_queue" + } else { + "github_auto_merge" + } + .into(), + status: status.into(), + source_commit: text(value.get("headRefOid")).unwrap_or_default(), + position: value + .pointer("/mergeQueueEntry/position") + .and_then(Value::as_u64), + can_enable: open + && !queued + && !auto + && if queue { + writer + } else { + value["viewerCanEnableAutoMerge"].as_bool() == Some(true) + }, + can_cancel: open + && if queued { + writer + } else { + auto && value["viewerCanDisableAutoMerge"].as_bool() == Some(true) + }, + blockers: if matches!(merge_status, "CLEAN" | "HAS_HOOKS") { + vec![] + } else { + vec![format!("GitHub merge status: {merge_status}")] + }, + strategies: [ + ("mergeCommitAllowed", "merge_commit"), + ("squashMergeAllowed", "squash"), + ("rebaseMergeAllowed", "rebase"), + ] + .iter() + .filter(|(field, _)| value["repository"][*field].as_bool() == Some(true)) + .map(|(_, strategy)| (*strategy).into()) + .collect(), + } +} + +pub fn azure(value: &Value, viewer: Option<&str>) -> Completion { + let author = text(value.pointer("/createdBy/uniqueName")); + let setter = text(value.pointer("/autoCompleteSetBy/uniqueName")); + let enabled = text(value.pointer("/autoCompleteSetBy/id")) + .is_some_and(|id| !id.is_empty() && id != "00000000-0000-0000-0000-000000000000"); + let open = + value["status"].as_str() == Some("active") && value["isDraft"].as_bool() == Some(false); + // Azure has no viewerCanUpdate field. As with draft handoff, fail closed to + // the provider-authenticated PR author (or the existing auto-complete owner). + let owns = viewer.is_some_and(|v| author.as_deref().is_some_and(|a| a.eq_ignore_ascii_case(v))); + let owns_auto = + viewer.is_some_and(|v| setter.as_deref().is_some_and(|a| a.eq_ignore_ascii_case(v))); + let merge_status = value["mergeStatus"].as_str().unwrap_or("unknown"); + Completion { + kind: "azure_auto_complete".into(), + status: match value["status"].as_str() { + Some("completed") => "merged", + Some("abandoned") => "closed", + _ if enabled => "waiting_for_policies", + _ => "disabled", + } + .into(), + source_commit: text(value.pointer("/lastMergeSourceCommit/commitId")).unwrap_or_default(), + position: None, + can_enable: open && !enabled && owns, + can_cancel: open && enabled && (owns || owns_auto), + blockers: if merge_status == "succeeded" { + vec![] + } else { + vec![format!("Azure merge status: {merge_status}")] + }, + strategies: vec!["merge_commit".into(), "squash".into(), "rebase".into()], + } +} + +pub fn set( + path: &str, + id: u64, + enable: bool, + strategy: PullRequestMergeStrategy, + expected_head: &str, +) -> Result<()> { + validate_commit(expected_head)?; + let (_, host) = host_for_path(path)?; + match host { + HostRepo::Hosted(_) => return Err("Deferred completion is unavailable for this provider".into()), + HostRepo::GitHub { host, owner, repo } => { + let query = format!("query($owner: String!, $repo: String!, $number: Int!) {{ repository(owner: $owner, name: $repo) {{ pullRequest(number: $number) {{ {GITHUB_FIELDS} }} }} }}"); + let value = pages::query( + &GitHubContext { path, host: &host }, + &query, + serde_json::json!({"owner":owner,"repo":repo,"number":id}), + None, + )?; + let pr = &value["data"]["repository"]["pullRequest"]; + let state = github(pr); + authorize(&state, enable, expected_head)?; + let node_id = text(pr.get("id")).ok_or("GitHub returned no PR node ID")?; + let (query, input) = + github_mutation(&state, enable, strategy, &node_id, expected_head)?; + pages::query(&GitHubContext { path, host: &host }, query, serde_json::json!({"input":input}), None)?; + } + HostRepo::Azure { + organization, + project, + repo, + } => { + let viewer = azure_viewer(path)?; + let value = azure_pr_value(path, &organization, id)?; + let state = azure(&value, Some(&viewer)); + authorize(&state, enable, expected_head)?; + let viewer_id = if enable { + text(value.pointer("/createdBy/id")).ok_or("Azure returned no author ID")? + } else { + String::new() + }; + azure_invoke_write_json( + path, + &organization, + "pullRequests", + &[ + format!("project={project}"), + format!("repositoryId={repo}"), + format!("pullRequestId={id}"), + ], + "PATCH", + &azure_payload(enable, &viewer_id, strategy, expected_head), + )?; + } + HostRepo::AzureServer { + profile_id, + project, + repo, + .. + } => { + let viewer = azure_server_viewer(profile_id)?; + let value = server_show(profile_id, &project, &repo, id)?; + authorize(&azure(&value, Some(&viewer)), enable, expected_head)?; + server_execute( + profile_id, + AzdoOperation::SetAutoComplete { + project, + repository: repo, + id, + enabled: enable, + expected_head: expected_head.into(), + viewer_id: if enable { + azure_server_viewer_id(profile_id)? + } else { + String::new() + }, + strategy: match strategy { + PullRequestMergeStrategy::MergeCommit => AzdoMergeStrategy::MergeCommit, + PullRequestMergeStrategy::Squash => AzdoMergeStrategy::Squash, + PullRequestMergeStrategy::Rebase => AzdoMergeStrategy::Rebase, + }, + }, + )?; + } + } + Ok(()) +} + +fn authorize(state: &Completion, enable: bool, expected: &str) -> Result<()> { + if enable { + ensure_review_head(&state.source_commit, expected)?; + } + if if enable { + !state.can_enable + } else { + !state.can_cancel + } { + return Err( + "The provider no longer permits this completion action. Refresh the pull request." + .into(), + ); + } + Ok(()) +} + +fn github_mutation( + state: &Completion, + enable: bool, + strategy: PullRequestMergeStrategy, + id: &str, + head: &str, +) -> Result<(&'static str, Value)> { + if state.status == "queued" && !enable { + return Ok(("mutation($input: DequeuePullRequestInput!) { dequeuePullRequest(input: $input) { clientMutationId } }", serde_json::json!({"pullRequestId":id}))); + } + if !enable { + return Ok(("mutation($input: DisablePullRequestAutoMergeInput!) { disablePullRequestAutoMerge(input: $input) { clientMutationId } }", serde_json::json!({"pullRequestId":id}))); + } + if state.kind == "github_queue" { + return Ok(("mutation($input: EnqueuePullRequestInput!) { enqueuePullRequest(input: $input) { clientMutationId } }", serde_json::json!({"pullRequestId":id,"expectedHeadOid":head,"jump":false}))); + } + let (key, method) = match strategy { + PullRequestMergeStrategy::MergeCommit => ("merge_commit", "MERGE"), + PullRequestMergeStrategy::Squash => ("squash", "SQUASH"), + PullRequestMergeStrategy::Rebase => ("rebase", "REBASE"), + }; + if !state.strategies.iter().any(|s| s == key) { + return Err("This merge strategy is disabled by the repository".into()); + } + Ok(("mutation($input: EnablePullRequestAutoMergeInput!) { enablePullRequestAutoMerge(input: $input) { clientMutationId } }", serde_json::json!({"pullRequestId":id,"expectedHeadOid":head,"mergeMethod":method}))) +} + +pub fn github_merge_payload(strategy: PullRequestMergeStrategy, head: &str) -> Value { + serde_json::json!({"sha":head,"merge_method":match strategy { PullRequestMergeStrategy::MergeCommit => "merge", PullRequestMergeStrategy::Squash => "squash", PullRequestMergeStrategy::Rebase => "rebase" }}) +} + +pub fn azure_payload( + enable: bool, + viewer: &str, + strategy: PullRequestMergeStrategy, + head: &str, +) -> Value { + if !enable { + return serde_json::json!({"autoCompleteSetBy":{"id":"00000000-0000-0000-0000-000000000000"}}); + } + serde_json::json!({"autoCompleteSetBy":{"id":viewer}, "lastMergeSourceCommit":{"commitId":head}, "completionOptions":{"mergeStrategy":azure_merge_strategy(strategy),"deleteSourceBranch":false,"transitionWorkItems":false,"bypassPolicy":false}}) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn queue_and_auto_merge_are_distinct_and_guard_enable_atomically() { + let mut raw = serde_json::json!({"state":"OPEN","isDraft":false,"headRefOid":"a".repeat(40),"isMergeQueueEnabled":true,"repository":{"viewerPermission":"WRITE","squashMergeAllowed":true}}); + let state = github(&raw); + assert!(state.can_enable); + let (query, input) = github_mutation( + &state, + true, + PullRequestMergeStrategy::Squash, + "PR_1", + &state.source_commit, + ) + .unwrap(); + assert!(query.contains("EnqueuePullRequestInput")); + assert_eq!(input["expectedHeadOid"], state.source_commit); + assert_eq!(input["jump"], false); + assert!(authorize(&state, true, &"b".repeat(40)).is_err()); + raw["isInMergeQueue"] = true.into(); + raw["mergeQueueEntry"] = serde_json::json!({"position":3}); + let queued = github(&raw); + assert_eq!(queued.status, "queued"); + assert_eq!(queued.position, Some(3)); + assert!(!queued.can_enable); + assert!(queued.can_cancel); + assert!( + github_mutation(&queued, false, PullRequestMergeStrategy::Squash, "PR_1", "") + .unwrap() + .0 + .contains("Dequeue") + ); + raw["isMergeQueueEnabled"] = false.into(); + raw["isInMergeQueue"] = false.into(); + raw["viewerCanEnableAutoMerge"] = true.into(); + let auto = github(&raw); + assert!(github_mutation( + &auto, + true, + PullRequestMergeStrategy::Squash, + "PR_1", + &auto.source_commit + ) + .unwrap() + .0 + .contains("EnablePullRequestAutoMerge")); + assert!(!github(&serde_json::json!({})).can_enable); + } + #[test] + fn azure_reports_waiting_policies_without_queue_position_and_cancel_keeps_options() { + let raw = serde_json::json!({"status":"active","isDraft":false,"createdBy":{"uniqueName":"ada"},"autoCompleteSetBy":{"id":"id","uniqueName":"ada"}}); + let state = azure(&raw, Some("ADA")); + assert_eq!(state.status, "waiting_for_policies"); + assert!(state.position.is_none()); + assert!(state.can_cancel); + assert!(!state.can_enable); + assert!(!azure(&raw, None).can_cancel); + let cancel = azure_payload(false, "", PullRequestMergeStrategy::Squash, ""); + assert!(cancel.get("completionOptions").is_none()); + let enable = azure_payload(true, "viewer", PullRequestMergeStrategy::Squash, "head"); + assert_eq!(enable["lastMergeSourceCommit"]["commitId"], "head"); + assert_eq!(enable["completionOptions"]["bypassPolicy"], false); + assert!(enable.get("status").is_none()); + } +} diff --git a/crates/strand-tauri/src/pull_requests/evolution.rs b/crates/strand-tauri/src/pull_requests/evolution.rs new file mode 100644 index 00000000..25c09eef --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/evolution.rs @@ -0,0 +1,684 @@ +//! Immutable review boundaries and validated local application of hosted feedback. +use super::*; +use base64::Engine; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Debug, Clone, Serialize)] +pub struct Boundary { + pub head: String, + pub label: String, + pub iteration: Option, +} + +#[derive(Debug, Serialize)] +pub struct Comparison { + pub from: String, + pub to: String, + pub history_rewritten: bool, + pub diffs: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Feedback { + pub source_commit: String, + pub threads: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SuggestionRequest { + pub thread_id: String, + pub comment_id: String, + pub suggestion_index: usize, + pub expected_head: String, + pub expected_body: String, +} + +#[derive(Debug, PartialEq, Deserialize, Serialize)] +pub struct SuggestionPreview { + pub path: String, + pub start_line: u32, + pub end_line: u32, + pub before: String, + pub after: String, + pub expected_file: String, +} + +fn current_head(path: &str, host: &HostRepo, id: u64) -> Result { + match host { + HostRepo::Hosted(_) => Err("Review evolution is unavailable for this provider".into()), + HostRepo::GitHub { host, owner, repo } => github_current_head(&GitHubContext { path, host }, owner, repo, id), + HostRepo::Azure { organization, .. } => { + text(azure_pr_value(path, organization, id)?.pointer("/lastMergeSourceCommit/commitId")) + .ok_or("Azure returned no source commit".into()) + } + HostRepo::AzureServer { + profile_id, + project, + repo, + .. + } => text( + server_show(*profile_id, project, repo, id)?.pointer("/lastMergeSourceCommit/commitId"), + ) + .ok_or("Azure Server returned no source commit".into()), + } +} + +fn iterations(path: &str, host: &HostRepo, id: u64) -> Result { + match host { + HostRepo::Azure { + organization, + project, + repo, + } => azure_invoke_json( + path, + organization, + "pullRequestIterations", + &[ + format!("project={project}"), + format!("repositoryId={repo}"), + format!("pullRequestId={id}"), + ], + &[], + ), + HostRepo::AzureServer { + profile_id, + project, + repo, + .. + } => server_execute( + *profile_id, + AzdoOperation::PullRequestIterations { + project: project.clone(), + repository: repo.clone(), + id, + }, + ), + _ => Err("This provider has no iterations".into()), + } +} + +pub fn boundaries(path: &str, id: u64, head: &str, request_id: &str) -> Result> { + validate_commit(head)?; + let guard = pages::ReadGuard::new(request_id)?; + let (_, host) = host_for_path(path)?; + ensure_review_head(¤t_head(path, &host, id)?, head)?; + check_cancelled(&guard.cancelled)?; + let mut result = Vec::new(); + match host { + HostRepo::GitHub { .. } => { + let mut request = pages::Cursor { + kind: pages::Kind::Reviews, + thread_id: None, + cursor: None, + total: None, + error: None, + }; + let mut cursors = std::collections::HashSet::new(); + loop { + let page = pages::read_cancellable(path, id, head, request, &guard.cancelled)?; + result.extend(page.reviews.into_iter().filter_map(|review| { + Some(Boundary { + head: review.source_commit?, + label: format!( + "{} · {} · {}", + review.author, review.state, review.submitted_at + ), + iteration: None, + }) + })); + let Some(next) = page.pending.into_iter().next() else { + break; + }; + if !cursors.insert(next.cursor.clone()) { + return Err("Repeated review cursor".into()); + } + request = next; + } + } + _ => { + let value = iterations(path, &host, id)?; + azure_latest_iteration(&value, head)?; + result.extend( + value + .as_array() + .or_else(|| value.get("value").and_then(Value::as_array)) + .into_iter() + .flatten() + .filter_map(|iteration| { + let number = u32::try_from(iteration.get("id")?.as_u64()?).ok()?; + Some(Boundary { + head: text(iteration.pointer("/sourceRefCommit/commitId"))?, + label: format!( + "Iteration {number} · {}", + text(iteration.get("createdDate")).unwrap_or_default() + ), + iteration: Some(number), + }) + }), + ); + } + } + let mut seen = std::collections::HashSet::new(); + result.retain(|boundary| seen.insert(boundary.head.clone())); + check_cancelled(&guard.cancelled)?; + Ok(result) +} + +pub fn compare(path: &str, id: u64, from: &str, head: &str) -> Result { + validate_commit(from)?; + validate_commit(head)?; + let (remote, host) = host_for_path(path)?; + ensure_review_head(¤t_head(path, &host, id)?, head)?; + let local = Repo::discover(path).map_err(|e| e.to_string())?; + if local.diff_between(head, head).is_err() { + prepare_checkout(path, id, head)?; + } + if local.diff_between(from, from).is_err() { + local.fetch_refs_for_read(&remote, &[from]).map_err(|e| format!("The reviewed commit is unavailable after a rebase or force push. Its boundary is preserved; fetch that exact commit if the provider still retains it. {e}"))?; + } + compare_local(&local, from, head) +} + +fn compare_local(local: &Repo, from: &str, head: &str) -> Result { + let diffs = local.diff_between(from, head).map_err(|e| e.to_string())?; + if diffs.iter().map(|d| d.patch.len()).sum::() > MAX_DIFF_BYTES { + return Err("Review comparison exceeds the 16 MB display limit".into()); + } + Ok(Comparison { + from: from.into(), + to: head.into(), + history_rewritten: local.merge_base(from, head).ok().as_deref() != Some(from), + diffs, + }) +} + +/// Explicit feedback export/preview may exhaust discussion pages. Inbox and +/// background reads never enter this path. Stop at any error, not a partial export. +fn check_cancelled(cancelled: &AtomicBool) -> Result<()> { + if cancelled.load(Ordering::Relaxed) { + Err("Read cancelled".into()) + } else { + Ok(()) + } +} + +fn discussion(path: &str, id: u64, head: &str, request_id: &str) -> Result { + let guard = pages::ReadGuard::new(request_id)?; + let (_, host) = host_for_path(path)?; + let mut threads; + if matches!(host, HostRepo::GitHub { .. }) { + threads = Vec::::new(); + let mut pending = std::collections::VecDeque::from([pages::Cursor { + kind: pages::Kind::Threads, + thread_id: None, + cursor: None, + total: None, + error: None, + }]); + let mut seen = std::collections::HashSet::new(); + while let Some(request) = pending.pop_front() { + check_cancelled(&guard.cancelled)?; + if !seen.insert(format!( + "{:?}:{:?}:{:?}", + request.kind, request.thread_id, request.cursor + )) { + return Err("Repeated discussion cursor".into()); + } + let page = pages::read_cancellable(path, id, head, request.clone(), &guard.cancelled)?; + pending.extend(page.pending); + merge_discussion( + &mut threads, + page.review_threads, + request.thread_id.as_deref(), + page.comments, + )?; + if threads + .iter() + .flat_map(|t| &t.comments) + .map(|c| c.body.len()) + .sum::() + > MAX_DIFF_BYTES + { + return Err("Feedback exceeds the 16 MB export limit".into()); + } + } + } else { + let pr = detail(path, id)?; + ensure_review_head(&pr.source_commit, head)?; + threads = pr.review_threads; + } + check_cancelled(&guard.cancelled)?; + if threads + .iter() + .flat_map(|t| &t.comments) + .map(|c| c.body.len()) + .sum::() + > MAX_DIFF_BYTES + { + return Err("Feedback exceeds the 16 MB export limit".into()); + } + ensure_review_head(¤t_head(path, &host, id)?, head)?; + check_cancelled(&guard.cancelled)?; + Ok(Feedback { + source_commit: head.into(), + threads, + }) +} + +fn merge_discussion( + threads: &mut Vec, + incoming: Vec, + reply_to: Option<&str>, + comments: Vec, +) -> Result<()> { + for mut thread in incoming { + if let Some(existing) = threads.iter_mut().find(|t| t.id == thread.id) { + let mut ids = thread + .comments + .iter() + .map(|c| c.id.clone()) + .collect::>(); + thread.comments.extend( + existing + .comments + .drain(..) + .filter(|c| ids.insert(c.id.clone())), + ); + *existing = thread; + } else { + threads.push(thread); + } + } + if let Some(id) = reply_to { + let thread = threads + .iter_mut() + .find(|t| t.id == id) + .ok_or("Reply page has no loaded thread")?; + let mut ids = thread + .comments + .iter() + .map(|c| c.id.clone()) + .collect::>(); + thread.comments.extend( + comments + .into_iter() + .filter(|c| ids.insert(c.id.clone())) + .map(|mut c| { + c.path = Some(thread.path.clone()); + c + }), + ); + } + Ok(()) +} + +pub fn feedback(path: &str, id: u64, head: &str, request_id: &str) -> Result { + validate_commit(head)?; + let mut feedback = discussion(path, id, head, request_id)?; + feedback + .threads + .retain(|t| !t.is_resolved && t.comments.iter().any(|c| !c.is_system)); + Ok(feedback) +} + +/// Standard provider suggestion fences only. No guessed offset semantics. +pub fn suggestions(body: &str) -> Vec { + let mut result = Vec::new(); + let mut fence: Option<(usize, bool, Vec<&str>)> = None; + for line in body.lines() { + let trimmed = line.trim(); + if let Some((width, wanted, lines)) = &mut fence { + if trimmed.len() >= *width && trimmed.chars().all(|c| c == '`') { + if *wanted { + result.push(if lines.is_empty() { + String::new() + } else { + lines.join("\n") + "\n" + }); + } + fence = None; + } else { + lines.push(line); + } + } else { + let width = trimmed.chars().take_while(|c| *c == '`').count(); + if width >= 3 { + fence = Some((width, trimmed[width..].trim() == "suggestion", Vec::new())); + } + } + } + result +} + +fn suggestion_content( + path: &str, + id: u64, + request: &SuggestionRequest, + request_id: &str, +) -> Result<(SuggestionPreview, String)> { + validate_commit(&request.expected_head)?; + validate_comment(&request.expected_body)?; + let pr = discussion(path, id, &request.expected_head, request_id)?; + let thread = pr + .threads + .iter() + .find(|t| t.id == request.thread_id) + .ok_or("Review thread no longer exists")?; + if !thread.suggestion_range_valid + || thread.is_resolved + || thread.is_outdated + || thread.side != PullRequestDiffSide::Additions + { + return Err("Only unresolved suggestions with an unambiguous full-line range on the current source can be applied".into()); + } + let (_, host) = host_for_path(path)?; + if !matches!(host, HostRepo::GitHub { .. }) { + let current = + azure_latest_iteration(&iterations(path, &host, id)?, &request.expected_head)?; + if thread.iteration_id != Some(current) { + return Err("Suggestion belongs to an older or unknown Azure iteration; ask for feedback on the current iteration".into()); + } + } + let comment = thread + .comments + .iter() + .find(|c| c.id == request.comment_id) + .ok_or("Suggestion comment no longer exists")?; + if comment.body != request.expected_body { + return Err("Suggestion changed since preview; reload feedback".into()); + } + let replacement = suggestions(&comment.body) + .get(request.suggestion_index) + .cloned() + .ok_or("Standard suggestion block no longer exists")?; + let local = Repo::discover(path).map_err(|e| e.to_string())?; + let expected = clean_source_file(&local, &thread.path, &request.expected_head)?; + let (before, content) = + replace_lines(&expected, thread.start_line, thread.end_line, &replacement)?; + Ok(( + SuggestionPreview { + path: thread.path.clone(), + start_line: thread.start_line, + end_line: thread.end_line, + before, + after: replacement, + expected_file: expected, + }, + content, + )) +} + +pub fn preview( + path: &str, + id: u64, + request: &SuggestionRequest, + request_id: &str, +) -> Result { + suggestion_content(path, id, request, request_id).map(|(preview, _)| preview) +} + +pub fn apply( + path: &str, + id: u64, + request: &SuggestionRequest, + expected_preview: &SuggestionPreview, + request_id: &str, +) -> Result { + let (preview, content) = suggestion_content(path, id, request, request_id)?; + if &preview != expected_preview { + return Err( + "File or suggestion coordinates changed since preview; preview the suggestion again" + .into(), + ); + } + let local = Repo::discover(path).map_err(|e| e.to_string())?; + let (_, host) = host_for_path(path)?; + ensure_review_head(¤t_head(path, &host, id)?, &request.expected_head)?; + apply_local( + &local, + &preview.path, + &request.expected_head, + &expected_preview.expected_file, + &content, + )?; + Ok(preview.path) +} + +fn clean_source_file(local: &Repo, file: &str, head: &str) -> Result { + if file.is_empty() + || file.contains(':') + || file.starts_with(['/', '\\']) + || file + .split(['/', '\\']) + .any(|p| p == ".." || p.eq_ignore_ascii_case(".git")) + { + return Err("Suggestion path must be a file inside the working tree".into()); + } + if local.meta().map_err(|e| e.to_string())?.head_oid.as_deref() != Some(head) { + return Err("Open the PR's exact head in a worktree before applying suggestions".into()); + } + let base = local + .file_content(file, Some(head)) + .map_err(|e| e.to_string())?; + let working = local.file_content(file, None).map_err(|e| e.to_string())?; + if !base.editable || !working.editable || base.truncated || working.truncated { + return Err("Suggestion requires a complete UTF-8 text file".into()); + } + let index = local + .file_blob(file, strand_core::file::BlobSource::Index) + .map_err(|e| e.to_string())?; + let index_bytes = base64::engine::general_purpose::STANDARD + .decode(index.base64) + .map_err(|e| e.to_string())?; + if index.too_large || index_bytes != base.text.as_bytes() { + return Err( + "File has staged changes; preserve them before applying this suggestion".into(), + ); + } + if working.text.replace("\r\n", "\n") != base.text.replace("\r\n", "\n") { + return Err("File has local changes; preserve them before applying this suggestion".into()); + } + Ok(working.text) +} + +fn apply_local(local: &Repo, file: &str, head: &str, expected: &str, content: &str) -> Result<()> { + if clean_source_file(local, file, head)? != expected { + return Err("File changed since preview".into()); + } + local + .write_file_content(file, expected, content) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn replace_lines(text: &str, start: u32, end: u32, replacement: &str) -> Result<(String, String)> { + let lines = text.split_inclusive('\n').collect::>(); + if start == 0 || end < start || end as usize > lines.len() { + return Err("Suggestion range is outside the current file".into()); + } + let before = lines[start as usize - 1..end as usize].concat(); + let crlf = text.contains("\r\n") && text.matches("\r\n").count() == text.matches('\n').count(); + let eol = if crlf { "\r\n" } else { "\n" }; + let mut after = replacement.replace("\r\n", "\n").replace('\n', eol); + if !after.is_empty() && !after.ends_with(eol) && before.ends_with('\n') { + after.push_str(eol); + } + if !before.ends_with('\n') && after.ends_with(eol) { + after.truncate(after.len() - eol.len()); + } + Ok(( + before, + format!( + "{}{}{}", + lines[..start as usize - 1].concat(), + after, + lines[end as usize..].concat() + ), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + fn git(path: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .current_dir(path) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().into() + } + #[test] + fn suggestions_preserve_ranges_newlines_and_reject_ambiguous_fences() { + assert_eq!( + suggestions("```text\n```suggestion\nno\n```\n"), + Vec::::new() + ); + assert_eq!( + suggestions("```suggestion\nx\ny\n```\n```suggestion\n```"), + vec!["x\ny\n", ""] + ); + assert!(suggestions("```suggestion:-1+2\nx\n```").is_empty()); + assert!(suggestions("```suggestion\nunterminated").is_empty()); + assert_eq!( + replace_lines("a\r\nb\r\nc\r\n", 2, 2, "x\ny").unwrap().1, + "a\r\nx\r\ny\r\nc\r\n" + ); + assert_eq!(replace_lines("a\nb", 2, 2, "x").unwrap().1, "a\nx"); + assert_eq!(replace_lines("a\nb\nc\n", 2, 2, "").unwrap().1, "a\nc\n"); + assert!(replace_lines("a\n", 0, 1, "x").is_err()); + assert!(replace_lines("a\n", 1, 2, "x").is_err()); + } + #[test] + fn discussion_pages_keep_101_replies_and_file_feedback_with_safe_coordinates() { + let value = serde_json::json!({ "data": { "repository": { "pullRequest": { "reviewThreads": { "nodes": [ + { "id": "T", "path": "file.txt", "line": 2, "diffSide": "RIGHT", "comments": { "nodes": [{ "id": "0", "body": "root" }] } }, + { "id": "F", "path": "file.txt", "comments": { "nodes": [{ "id": "file", "body": "file feedback" }] } } + ] } } } } }); + let parsed = parse_github_review_threads(&value); + assert!(parsed[0].suggestion_range_valid); + assert!(!parsed[1].suggestion_range_valid); + assert_eq!(parsed[1].end_line, 0); + let mut threads = Vec::new(); + merge_discussion(&mut threads, parsed.clone(), None, Vec::new()).unwrap(); + let replies = (0..101) + .map(|i| { + let mut comment = parsed[0].comments[0].clone(); + comment.id = i.to_string(); + comment + }) + .collect::>(); + merge_discussion(&mut threads, Vec::new(), Some("T"), replies.clone()).unwrap(); + merge_discussion(&mut threads, parsed, Some("T"), replies).unwrap(); + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].comments.len(), 101); + assert_eq!(threads[0].comments[100].path.as_deref(), Some("file.txt")); + assert!(merge_discussion(&mut threads, Vec::new(), Some("missing"), Vec::new()).is_err()); + assert!(check_cancelled(&AtomicBool::new(true)).is_err()); + } + + #[test] + fn provider_boundaries_and_ambiguous_ranges_are_not_guessed() { + let mut value = serde_json::json!({ "data": { "repository": { "pullRequest": { + "reviews": { "nodes": [{ "id": "R", "commit": { "oid": "a".repeat(40) } }] }, + "reviewThreads": { "nodes": [{ "id": "T", "path": "file.txt", "line": 3, "startLine": 2, "startDiffSide": "LEFT", "diffSide": "RIGHT", "comments": { "nodes": [{ "id": "C", "body": "feedback" }] } }] } + } } } }); + assert_eq!( + parse_github_reviews(&value)[0].source_commit.as_deref(), + Some("a".repeat(40).as_str()) + ); + assert!(!parse_github_review_threads(&value)[0].suggestion_range_valid); + value["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"][0]["startDiffSide"] = + "RIGHT".into(); + assert!(parse_github_review_threads(&value)[0].suggestion_range_valid); + value["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"][0]["startLine"] = Value::Null; + value["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"][0]["originalStartLine"] = 99.into(); + assert_eq!(parse_github_review_threads(&value)[0].start_line, 3); + let mut azure = serde_json::json!({ "value": [{ "id": 1, "status": "active", "threadContext": { "filePath": "/file.txt", "rightFileStart": { "line": 2, "offset": 1 }, "rightFileEnd": { "line": 3, "offset": 1 } }, "pullRequestThreadContext": { "iterationContext": { "secondComparingIteration": 3 } }, "comments": [{ "id": 1, "content": "feedback", "commentType": "text" }] }] }); + let parsed = parse_azure_review_threads(&azure, "https://example.test/pr/1", 1); + assert_eq!(parsed[0].iteration_id, Some(3)); + assert!(parsed[0].suggestion_range_valid); + azure["value"][0]["threadContext"]["rightFileEnd"]["offset"] = 12.into(); + assert!( + !parse_azure_review_threads(&azure, "https://example.test/pr/1", 1)[0] + .suggestion_range_valid + ); + assert_eq!( + replace_lines("a\nb\n", 2, 2, &suggestions("```suggestion\n\n```")[0]) + .unwrap() + .1, + "a\n\n" + ); + } + #[test] + fn local_apply_checks_head_index_worktree_and_preserves_crlf() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path(); + git(path, &["init", "-q", "-b", "main"]); + git(path, &["config", "user.name", "Fixture"]); + git(path, &["config", "user.email", "fixture@example.test"]); + git(path, &["config", "commit.gpgsign", "false"]); + git(path, &["config", "core.autocrlf", "false"]); + std::fs::write(path.join("file.txt"), "a\r\nb\r\n").unwrap(); + git(path, &["add", "."]); + git(path, &["commit", "-qm", "initial"]); + let head = git(path, &["rev-parse", "HEAD"]); + let local = Repo::discover(path).unwrap(); + let expected = clean_source_file(&local, "file.txt", &head).unwrap(); + assert!(clean_source_file(&local, "../file.txt", &head).is_err()); + assert!(clean_source_file(&local, ".git/config", &head).is_err()); + assert!(clean_source_file(&local, "file.txt", &"b".repeat(40)).is_err()); + std::fs::write(path.join("file.txt"), "dirty\n").unwrap(); + assert!(clean_source_file(&local, "file.txt", &head).is_err()); + git(path, &["add", "."]); + std::fs::write(path.join("file.txt"), &expected).unwrap(); + let local = Repo::discover(path).unwrap(); + assert!(clean_source_file(&local, "file.txt", &head).is_err()); + git(path, &["reset", "-q", "HEAD", "--", "file.txt"]); + let local = Repo::discover(path).unwrap(); + let (_, content) = replace_lines(&expected, 2, 2, "changed").unwrap(); + apply_local(&local, "file.txt", &head, &expected, &content).unwrap(); + assert_eq!( + std::fs::read_to_string(path.join("file.txt")).unwrap(), + "a\r\nchanged\r\n" + ); + assert_eq!(git(path, &["diff", "--cached"]), ""); + assert!(apply_local(&local, "file.txt", &head, &expected, &content).is_err()); + } + #[test] + fn rewritten_history_compares_the_explicit_trees() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path(); + git(path, &["init", "-q", "-b", "main"]); + git(path, &["config", "user.name", "Fixture"]); + git(path, &["config", "user.email", "fixture@example.test"]); + git(path, &["config", "commit.gpgsign", "false"]); + std::fs::write(path.join("file.txt"), "base\n").unwrap(); + git(path, &["add", "."]); + git(path, &["commit", "-qm", "base"]); + let base = git(path, &["rev-parse", "HEAD"]); + std::fs::write(path.join("file.txt"), "reviewed\n").unwrap(); + git(path, &["commit", "-qam", "reviewed"]); + let reviewed = git(path, &["rev-parse", "HEAD"]); + git(path, &["reset", "--hard", &base]); + std::fs::write(path.join("file.txt"), "rebased\n").unwrap(); + git(path, &["commit", "-qam", "rebased"]); + let head = git(path, &["rev-parse", "HEAD"]); + let local = Repo::discover(path).unwrap(); + let compared = compare_local(&local, &reviewed, &head).unwrap(); + assert!(compared.history_rewritten); + assert!(compared.diffs[0].patch.contains("-reviewed")); + assert!(compared.diffs[0].patch.contains("+rebased")); + assert!( + !compare_local(&local, &base, &head) + .unwrap() + .history_rewritten + ); + } +} diff --git a/crates/strand-tauri/src/pull_requests/hosted.rs b/crates/strand-tauri/src/pull_requests/hosted.rs new file mode 100644 index 00000000..7faf7c1a --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/hosted.rs @@ -0,0 +1,1338 @@ +//! GitLab and Bitbucket Cloud adapters. Existing GitHub/Azure paths stay separate. +use super::transport::{pages, segment, Api, Client}; +use super::*; +use serde_json::json; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct HostedRepo { + pub provider: String, + pub host: String, + pub namespace: String, + pub repo: String, +} + +impl HostedRepo { + fn bb(&self) -> bool { + self.provider == "bitbucket" + } + fn root(&self) -> String { + if self.bb() { + format!( + "repositories/{}/{}", + segment(&self.namespace), + segment(&self.repo) + ) + } else { + format!( + "projects/{}", + segment(&format!("{}/{}", self.namespace, self.repo)) + ) + } + } + fn prs(&self) -> String { + format!( + "{}/{}", + self.root(), + if self.bb() { + "pullrequests" + } else { + "merge_requests" + } + ) + } + fn pr(&self, id: u64) -> String { + format!("{}/{id}", self.prs()) + } + pub fn client<'a>(&'a self, cwd: &'a str) -> Client<'a> { + Client { + cwd, + provider: &self.provider, + host: &self.host, + } + } + fn repository(&self, remote: String, viewer: Option) -> PullRequestRepository { + PullRequestRepository { + provider: if self.bb() { + PullRequestProvider::Bitbucket + } else { + PullRequestProvider::GitLab + }, + remote, + label: format!("{}/{}/{}", self.host, self.namespace, self.repo), + viewer, + } + } + fn viewer(&self, api: &impl Api) -> Result { + api.json("GET", "user", None) + } + fn viewer_name(&self, viewer: &Value) -> String { + field( + viewer, + if self.bb() { + "/display_name" + } else { + "/username" + }, + ) + } + fn same_user(&self, a: &Value, b: &Value) -> bool { + let key = if self.bb() { "uuid" } else { "id" }; + a.get(key) + .is_some_and(|id| !id.is_null() && b.get(key) == Some(id)) + } + pub fn list( + &self, + api: &impl Api, + remote: String, + branch: Option<&str>, + ) -> Result { + let query = if self.bb() { + let filter = branch + .map(|b| { + format!( + "&q={}", + segment(&format!( + "source.branch.name={}", + json!(b.trim_start_matches("refs/heads/")) + )) + ) + }) + .unwrap_or_default(); + format!("{}?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&sort=-updated_on{filter}", self.prs()) + } else { + format!( + "{}?scope=all&state=all&order_by=updated_at&sort=desc{}", + self.prs(), + branch + .map(|b| format!( + "&source_branch={}", + segment(b.trim_start_matches("refs/heads/")) + )) + .unwrap_or_default() + ) + }; + let viewer = self.viewer(api)?; + let values = if branch.is_some() { + let value = api.json( + "GET", + &format!( + "{query}&{}=1", + if self.bb() { "pagelen" } else { "per_page" } + ), + None, + )?; + (if self.bb() { + value.get("values") + } else { + Some(&value) + }) + .and_then(Value::as_array) + .ok_or("Provider returned an invalid branch request lookup")? + .clone() + } else { + pages(api, &query, self.bb())? + }; + Ok(PullRequestList { + next_cursor: None, + total_count: Some(values.len() as u64), + repository: self.repository(remote, Some(self.viewer_name(&viewer))), + pull_requests: values + .iter() + .map(|v| self.parse(v, &viewer)) + .collect::>()?, + }) + } + fn parse(&self, v: &Value, viewer: &Value) -> Result { + let id = v + .get(if self.bb() { "id" } else { "iid" }) + .and_then(Value::as_u64) + .ok_or("Provider returned no request number")?; + let bb = self.bb(); + let author = &v["author"]; + let state = field(v, "/state"); + let state = match state.as_str() { + "opened" | "OPEN" => "open", + "MERGED" => "merged", + "DECLINED" | "SUPERSEDED" => "closed", + _ => &state, + } + .to_string(); + let authored = self.same_user(author, viewer); + let mut pr = PullRequest { + capabilities: Some(PullRequestCapabilities::default()), + id, + title: field(v, "/title"), + state, + is_draft: v["draft"].as_bool().unwrap_or(false), + author: self.viewer_name(author), + authored_by_viewer: authored, + source_branch: field( + v, + if bb { + "/source/branch/name" + } else { + "/source_branch" + }, + ), + source_commit: field(v, if bb { "/source/commit/hash" } else { "/sha" }), + target_branch: field( + v, + if bb { + "/destination/branch/name" + } else { + "/target_branch" + }, + ), + created_at: field(v, if bb { "/created_on" } else { "/created_at" }), + updated_at: field(v, if bb { "/updated_on" } else { "/updated_at" }), + completed_at: text(v.get("merged_at")).or_else(|| text(v.get("closed_at"))), + url: field(v, if bb { "/links/html/href" } else { "/web_url" }), + description: field(v, "/description"), + merge_status: field(v, "/detailed_merge_status"), + comment_count: v[if bb { + "comment_count" + } else { + "user_notes_count" + }] + .as_u64() + .unwrap_or(0) as usize, + labels: array(v, "labels") + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + ..PullRequest::default() + }; + if !bb { + pr.merge_status = match pr.merge_status.as_str() { + "mergeable" => "CLEAN", + "conflict" => "CONFLICTING", + "checking" | "approvals_syncing" => "CHECKING", + x => x, + } + .into(); + } + pr.reviewers = array(v, if bb { "participants" } else { "reviewers" }) + .iter() + .filter(|r| !bb || r["role"] == "REVIEWER") + .map(|r| PullRequestReviewer { + name: self.viewer_name(if bb { &r["user"] } else { r }), + required: false, + status: if r["approved"] == true { + "APPROVED" + } else if r["state"] == "changes_requested" { + "CHANGES_REQUESTED" + } else { + "REQUESTED" + } + .into(), + }) + .collect(); + Ok(pr) + } + fn permissions( + &self, + api: &impl Api, + v: &Value, + viewer: &Value, + ) -> Result { + let open = matches!(field(v, "/state").as_str(), "opened" | "OPEN"); + let author = self.same_user(&v["author"], viewer); + let write = if self.bb() { + let query = format!( + "user/workspaces/{}/permissions/repositories?q={}", + segment(&self.namespace), + segment(&format!( + "repository.full_name={}", + json!(format!("{}/{}", self.namespace, self.repo)) + )) + ); + pages(api, &query, true)? + .iter() + .any(|p| matches!(p["permission"].as_str(), Some("write" | "admin"))) + } else { + let project = api.json("GET", &self.root(), None)?; + [ + "/permissions/project_access/access_level", + "/permissions/group_access/access_level", + ] + .iter() + .any(|p| project.pointer(p).and_then(Value::as_u64).unwrap_or(0) >= 30) + }; + Ok(PullRequestCapabilities { + can_comment: open, + can_review: open && !author, + can_request_changes: open && !author && self.bb(), + can_close: open && (write || author), + can_reopen: !open && (write || author) && !self.bb() && v["state"] == "closed", + // Cloud merge has no expected-head guard. GitLab's merge method + // belongs to project settings; do not reinterpret it as rebase. + merge_strategies: if open && write && !self.bb() { + vec!["merge_commit".into(), "squash".into()] + } else { + vec![] + }, + }) + } + pub fn detail(&self, api: &impl Api, id: u64) -> Result { + let v = api.json("GET", &self.pr(id), None)?; + let viewer = self.viewer(api)?; + let mut pr = self.parse(&v, &viewer)?; + let capabilities = self.permissions(api, &v, &viewer)?; + pr.can_mark_ready = pr.is_draft && capabilities.can_close && !self.bb(); + pr.capabilities = Some(capabilities); + let commits = pages(api, &format!("{}/commits", self.pr(id)), self.bb())?; + pr.commits = commits + .iter() + .map(|c| PullRequestCommit { + id: field(c, if self.bb() { "/hash" } else { "/id" }), + title: field(c, if self.bb() { "/message" } else { "/title" }), + author: field( + c, + if self.bb() { + "/author/raw" + } else { + "/author_name" + }, + ), + avatar_url: None, + committed_at: field( + c, + if self.bb() { + "/date" + } else { + "/committed_date" + }, + ), + url: text(c.pointer(if self.bb() { + "/links/html/href" + } else { + "/web_url" + })), + }) + .collect(); + pr.commit_count = pr.commits.len(); + let discussions = pages( + api, + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "comments" } else { "discussions" } + ), + self.bb(), + )?; + self.discussions(&mut pr, &discussions); + if self.bb() { + let checks = pages(api, &format!("{}/statuses", self.pr(id)), true)?; + pr.checks = checks + .iter() + .map(|c| PullRequestCheck { + id: field(c, "/key"), + name: field(c, "/name"), + status: field(c, "/state"), + }) + .collect(); + // Commit statuses do not describe all Cloud merge restrictions. + pr.checks_complete = false; + } else { + if let Some(pipeline) = v.get("head_pipeline").filter(|p| !p.is_null()) { + pr.checks.push(PullRequestCheck { + id: pipeline["id"].to_string(), + name: "Head pipeline".into(), + status: field(pipeline, "/status"), + }); + } + // Approvals may be unavailable by tier/permission; absence is not green. + if let Ok(approval) = api.json("GET", &format!("{}/approvals", self.pr(id)), None) { + pr.review_status = if approval["approvals_left"].as_u64().unwrap_or(1) == 0 { + "APPROVED" + } else { + "REVIEW_REQUIRED" + } + .into(); + pr.reviews = array(&approval, "approved_by") + .iter() + .map(|r| PullRequestReview { + source_commit: None, + id: format!("gitlab:{id}:{}", r["user"]["id"]), + author: self.viewer_name(&r["user"]), + avatar_url: text(r["user"].get("avatar_url")), + state: "APPROVED".into(), + body: String::new(), + submitted_at: String::new(), + url: pr.url.clone(), + can_update: false, + can_dismiss: false, + }) + .collect(); + } + } + Ok(pr) + } + fn comment(&self, v: &Value, id: u64, url: &str) -> PullRequestComment { + let raw_id = v["id"].as_u64().unwrap_or(0); + PullRequestComment { + id: format!("{}:{id}:{raw_id}", self.provider), + author: self.viewer_name(&v[if self.bb() { "user" } else { "author" }]), + avatar_url: text(v.pointer(if self.bb() { + "/user/links/avatar/href" + } else { + "/author/avatar_url" + })), + body: field(v, if self.bb() { "/content/raw" } else { "/body" }), + created_at: field( + v, + if self.bb() { + "/created_on" + } else { + "/created_at" + }, + ), + url: format!( + "{url}#{}_{raw_id}", + if self.bb() { "comment" } else { "note" } + ), + is_system: v["system"] == true, + path: text(v.pointer(if self.bb() { + "/inline/path" + } else { + "/position/new_path" + })), + } + } + fn discussions(&self, pr: &mut PullRequest, values: &[Value]) { + let writable = pr.capabilities.as_ref().is_some_and(|c| c.can_comment); + let mut children: HashMap> = HashMap::new(); + if self.bb() { + for value in values.iter().filter(|v| v["deleted"] != true) { + pr.comments.push(self.comment(value, pr.id, &pr.url)); + if let Some(parent) = value["parent"]["id"].as_u64() { + children.entry(parent).or_default().push(value); + } + } + } + for v in values { + if self.bb() && (v["deleted"] == true || v.get("parent").is_some_and(|p| !p.is_null())) + { + continue; + } + let notes = if self.bb() { + vec![v] + } else { + array(v, "notes").iter().collect() + }; + let Some(first) = notes.first() else { + continue; + }; + let mut comments = notes + .iter() + .map(|n| self.comment(n, pr.id, &pr.url)) + .collect::>(); + if self.bb() { + let mut pending = vec![v["id"].as_u64().unwrap_or(0)]; + let mut visited = std::collections::HashSet::new(); + while let Some(parent) = pending.pop() { + if !visited.insert(parent) { + continue; + } + for reply in children.get(&parent).into_iter().flatten() { + comments.push(self.comment(reply, pr.id, &pr.url)); + if let Some(id) = reply["id"].as_u64() { + pending.push(id); + } + } + } + } else { + pr.comments.extend(comments.clone()); + } + let position = &first[if self.bb() { "inline" } else { "position" }]; + let old = if self.bb() { "from" } else { "old_line" }; + let new = if self.bb() { "to" } else { "new_line" }; + let addition = position[new].as_u64().is_some(); + let line = position[if addition { new } else { old }] + .as_u64() + .unwrap_or(0) as u32; + if line == 0 { + continue; + } + let resolved = if self.bb() { + first.get("resolution").is_some_and(|r| !r.is_null()) + } else { + first["resolved"] == true + }; + let outdated = !self.bb() + && position["head_sha"] + .as_str() + .is_some_and(|h| h != pr.source_commit); + let can_resolve = !self.bb() + && writable + && first["resolvable"] == true + && pr.capabilities.as_ref().is_some_and(|c| c.can_close); + let discussion_id = if self.bb() { + v["id"].to_string() + } else { + field(v, "/id") + }; + pr.review_threads.push(PullRequestReviewThread { + iteration_id: None, + suggestion_range_valid: false, + id: format!("{}:{}:{discussion_id}", self.provider, pr.id), + path: field( + position, + if self.bb() { + "/path" + } else if addition { + "/new_path" + } else { + "/old_path" + }, + ), + start_line: (if self.bb() { + position.get(if addition { "start_to" } else { "start_from" }) + } else { + position.pointer(if addition { + "/line_range/start/new_line" + } else { + "/line_range/start/old_line" + }) + }) + .and_then(Value::as_u64) + .unwrap_or(line as u64) as u32, + end_line: line, + side: if addition { + PullRequestDiffSide::Additions + } else { + PullRequestDiffSide::Deletions + }, + is_resolved: resolved, + is_outdated: outdated, + can_reply: writable, + can_resolve: can_resolve && !resolved, + can_unresolve: can_resolve && resolved, + comments, + }); + } + pr.comment_count = pr.comments.len(); + } + pub fn diff(&self, api: &impl Api, id: u64) -> Result { + let bytes = api.request( + "GET", + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "diff" } else { "raw_diffs" } + ), + None, + )?; + String::from_utf8(bytes).map_err(|_| "Provider returned a non-UTF-8 patch".into()) + } + fn current(&self, api: &impl Api, id: u64, expected: Option<&str>) -> Result { + let v = api.json("GET", &self.pr(id), None)?; + if !matches!(field(&v, "/state").as_str(), "opened" | "OPEN") { + return Err("This request is no longer open; refresh before writing".into()); + } + if let Some(expected) = expected { + ensure_review_head( + &field( + &v, + if self.bb() { + "/source/commit/hash" + } else { + "/sha" + }, + ), + expected, + )?; + } + Ok(v) + } + pub fn add_comment(&self, api: &impl Api, id: u64, body: &str) -> Result<()> { + self.current(api, id, None)?; + api.json( + "POST", + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "comments" } else { "notes" } + ), + Some(&if self.bb() { + json!({"content":{"raw":body}}) + } else { + json!({"body":body}) + }), + )?; + Ok(()) + } + pub fn inline( + &self, + api: &impl Api, + id: u64, + comment: &PullRequestPendingComment, + head: &str, + ) -> Result<()> { + let v = self.current(api, id, Some(head))?; + if self.bb() { + // Cloud does not accept an immutable commit coordinate on comments. + // Detect changes before and after; never report a raced write as safe. + let payload = bitbucket_inline(comment); + api.json("POST", &format!("{}/comments", self.pr(id)), Some(&payload))?; + self.current(api, id, Some(head)).map_err(|e| format!("Comment was posted, but the head changed. Inspect it on Bitbucket before retrying: {e}"))?; + } else { + let diffs = pages(api, &format!("{}/diffs", self.pr(id)), false)?; + let payload = gitlab_inline(&v, &diffs, comment, head)?; + self.current(api, id, Some(head))?; + api.json( + "POST", + &format!("{}/discussions", self.pr(id)), + Some(&payload), + )?; + } + Ok(()) + } + pub fn review( + &self, + api: &impl Api, + id: u64, + event: PullRequestReviewEvent, + body: &str, + comments: &[PullRequestPendingComment], + head: &str, + ) -> Result<()> { + let v = self.current(api, id, Some(head))?; + let viewer = self.viewer(api)?; + if event != PullRequestReviewEvent::Comment && self.same_user(&v["author"], &viewer) { + return Err("You cannot review your own request".into()); + } + if event == PullRequestReviewEvent::RequestChanges && !self.bb() { + return Err("Request changes on the GitLab website; this adapter supports comments and approvals".into()); + } + let mut posted = 0; + let result = (|| { + if event != PullRequestReviewEvent::Comment { + self.current(api, id, Some(head))?; + let verb = if event == PullRequestReviewEvent::Approve { + "approve" + } else { + "request-changes" + }; + api.json( + "POST", + &format!("{}/{verb}", self.pr(id)), + Some(&if self.bb() { + json!({}) + } else { + json!({"sha":head}) + }), + )?; + posted += 1; + } + for comment in comments { + self.inline(api, id, comment, head)?; + posted += 1; + } + if !body.trim().is_empty() { + self.current(api, id, Some(head))?; + self.add_comment(api, id, body)?; + posted += 1; + } + self.current(api, id, Some(head))?; + Ok(()) + })(); + result.map_err(|e: String| format!("{posted} review writes were confirmed; draft retained. Refresh and reconcile posted items before retrying. {e}")) + } + fn thread<'a>(&self, thread: &'a str) -> Result<(u64, &'a str)> { + let parts = thread.split(':').collect::>(); + if parts.len() != 3 + || parts[0] != self.provider + || !parts[2].bytes().all(|b| b.is_ascii_alphanumeric()) + { + return Err("Invalid provider discussion ID".into()); + } + Ok(( + parts[1].parse().map_err(|_| "Invalid request ID")?, + parts[2], + )) + } + pub fn reply(&self, api: &impl Api, thread: &str, body: &str) -> Result { + let (id, discussion) = self.thread(thread)?; + let current = self.current(api, id, None)?; + let url = field( + ¤t, + if self.bb() { + "/links/html/href" + } else { + "/web_url" + }, + ); + let endpoint = if self.bb() { + format!("{}/comments", self.pr(id)) + } else { + format!("{}/discussions/{discussion}/notes", self.pr(id)) + }; + let payload = if self.bb() { + json!({"content":{"raw":body},"parent":{"id":discussion.parse::().map_err(|_| "Invalid comment ID")?}}) + } else { + json!({"body":body}) + }; + let reply = api.json("POST", &endpoint, Some(&payload))?; + Ok(self.comment(&reply, id, &url)) + } + pub fn resolve( + &self, + api: &impl Api, + thread: &str, + resolved: bool, + ) -> Result { + if self.bb() { + return Err("Resolve this discussion on Bitbucket".into()); + } + let (id, discussion) = self.thread(thread)?; + let current = self.current(api, id, None)?; + let updated = api.json( + "PUT", + &format!("{}/discussions/{discussion}", self.pr(id)), + Some(&json!({"resolved":resolved})), + )?; + let note = array(&updated, "notes") + .iter() + .find(|n| n["resolvable"] == true) + .ok_or( + "GitLab updated the thread but returned no resolvable note; refresh its state", + )?; + let resolved = note["resolved"] + .as_bool() + .ok_or("GitLab returned no resolution state")?; + Ok(PullRequestReviewThreadUpdate { + id: thread.into(), + is_resolved: resolved, + is_outdated: note + .pointer("/position/head_sha") + .and_then(Value::as_str) + .is_some_and(|h| h != field(¤t, "/sha")), + can_reply: true, + can_resolve: !resolved, + can_unresolve: resolved, + }) + } + pub fn merge( + &self, + api: &impl Api, + id: u64, + strategy: PullRequestMergeStrategy, + head: &str, + ) -> Result<()> { + if self.bb() { + return Err( + "Merge on Bitbucket: its Cloud API cannot atomically guard the reviewed head" + .into(), + ); + } + if strategy == PullRequestMergeStrategy::Rebase { + return Err("GitLab merge method is controlled by project settings".into()); + } + self.current(api, id, Some(head))?; + api.json("PUT", &format!("{}/merge", self.pr(id)), Some(&json!({"sha":head,"squash":strategy == PullRequestMergeStrategy::Squash,"should_remove_source_branch":false})))?; + Ok(()) + } + pub fn lifecycle( + &self, + api: &impl Api, + id: u64, + action: PullRequestLifecycleAction, + ) -> Result<()> { + let v = api.json("GET", &self.pr(id), None)?; + let viewer = self.viewer(api)?; + let caps = self.permissions(api, &v, &viewer)?; + if !(if action == PullRequestLifecycleAction::Close { + caps.can_close + } else { + caps.can_reopen + }) { + return Err("This lifecycle action is not available to the signed-in account".into()); + } + if self.bb() { + api.json( + "POST", + &format!("{}/decline", self.pr(id)), + Some(&json!({})), + )?; + } else { + api.json("PUT", &self.pr(id), Some(&json!({"state_event":if action == PullRequestLifecycleAction::Close { "close" } else { "reopen" }})))?; + } + Ok(()) + } + pub fn ready(&self, api: &impl Api, id: u64) -> Result<()> { + if self.bb() { + return Err("Manage drafts on Bitbucket".into()); + } + let v = self.current(api, id, None)?; + let title = field(&v, "/title"); + let lower = title.to_ascii_lowercase(); + let prefix = ["draft:", "[draft]", "(draft)", "wip:", "[wip]", "(wip)"] + .iter() + .find(|prefix| lower.starts_with(**prefix)) + .ok_or("Unrecognized GitLab draft title; mark ready on the provider")?; + let title = title[prefix.len()..].trim_start(); + let updated = api.json("PUT", &self.pr(id), Some(&json!({"title":title})))?; + if updated["draft"] == true || updated["work_in_progress"] == true { + return Err( + "GitLab still reports this request as a draft; inspect it on the provider".into(), + ); + } + Ok(()) + } + pub fn create( + &self, + api: &impl Api, + source: &str, + target: &str, + title: &str, + description: &str, + draft: bool, + ) -> Result { + let payload = if self.bb() { + json!({"title":title,"description":description,"draft":draft,"source":{"branch":{"name":source}},"destination":{"branch":{"name":target}},"close_source_branch":false}) + } else { + json!({"source_branch":source,"target_branch":target,"title":if draft { format!("Draft: {title}") } else { title.into() },"description":description,"remove_source_branch":false}) + }; + let v = api.json("POST", &self.prs(), Some(&payload))?; + Ok(PullRequestCreateOutcome { + id: v[if self.bb() { "id" } else { "iid" }].as_u64().ok_or( + "Request created but number unavailable; check the provider before retrying", + )?, + url: field( + &v, + if self.bb() { + "/links/html/href" + } else { + "/web_url" + }, + ), + }) + } + pub fn checkout( + &self, + api: &impl Api, + cwd: &str, + remote: &str, + id: u64, + head: &str, + ) -> Result { + let v = api.json("GET", &self.pr(id), None)?; + ensure_review_head( + &field( + &v, + if self.bb() { + "/source/commit/hash" + } else { + "/sha" + }, + ), + head, + )?; + let branch = field( + &v, + if self.bb() { + "/source/branch/name" + } else { + "/source_branch" + }, + ); + let reference = if self.bb() { + if field(&v, "/source/repository/full_name") + != format!("{}/{}", self.namespace, self.repo) + { + return Err("Clone the Bitbucket fork to open this source branch locally".into()); + } + format!("refs/heads/{branch}") + } else { + format!("refs/merge-requests/{id}/head") + }; + Repo::discover(cwd) + .map_err(|e| e.to_string())? + .fetch_refs_for_read(remote, &[&reference]) + .map_err(|e| e.to_string())?; + Ok(PullRequestCheckoutPreparation { + branch, + start_point: head.into(), + }) + } + pub fn activity( + &self, + api: &impl Api, + remote: String, + id: u64, + ) -> Result { + // Monitoring must not reload commits, permissions, or patches. + let value = api.json("GET", &self.pr(id), None)?; + let mut pr = self.parse(&value, &Value::Null)?; + let discussions = pages( + api, + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "comments" } else { "discussions" } + ), + self.bb(), + )?; + self.discussions(&mut pr, &discussions); + if self.bb() { + pr.checks = pages(api, &format!("{}/statuses", self.pr(id)), true)? + .iter() + .map(|c| PullRequestCheck { + id: field(c, "/key"), + name: field(c, "/name"), + status: field(c, "/state"), + }) + .collect(); + pr.reviews = array(&value, "participants") + .iter() + .filter(|r| r["approved"] == true || r["state"] == "changes_requested") + .map(|r| PullRequestReview { + source_commit: None, + id: field(r, "/user/uuid"), + author: self.viewer_name(&r["user"]), + avatar_url: None, + state: if r["approved"] == true { + "APPROVED" + } else { + "CHANGES_REQUESTED" + } + .into(), + body: String::new(), + submitted_at: String::new(), + url: pr.url.clone(), + can_update: false, + can_dismiss: false, + }) + .collect(); + } else { + if let Some(pipeline) = value.get("head_pipeline").filter(|v| !v.is_null()) { + pr.checks.push(PullRequestCheck { + id: pipeline["id"].to_string(), + name: "Head pipeline".into(), + status: field(pipeline, "/status"), + }); + } + if let Ok(approval) = api.json("GET", &format!("{}/approvals", self.pr(id)), None) { + pr.reviews = array(&approval, "approved_by") + .iter() + .map(|r| PullRequestReview { + source_commit: None, + id: r["user"]["id"].to_string(), + author: self.viewer_name(&r["user"]), + avatar_url: None, + state: "APPROVED".into(), + body: String::new(), + submitted_at: String::new(), + url: pr.url.clone(), + can_update: false, + can_dismiss: false, + }) + .collect(); + } + } + Ok(PullRequestActivitySnapshot { + repository: self.repository(remote, None), + id, + title: pr.title, + url: pr.url, + state: pr.state, + source_branch: pr.source_branch, + source_commit: pr.source_commit, + updated_at: pr.updated_at, + comments: pr + .comments + .into_iter() + .map(|c| PullRequestActivityComment { + id: c.id, + author: c.author, + kind: "comment".into(), + is_system: c.is_system, + }) + .collect(), + reviews: pr + .reviews + .into_iter() + .map(|r| PullRequestActivityReview { + id: r.id, + author: r.author, + state: r.state, + }) + .collect(), + checks: pr + .checks + .into_iter() + .map(|c| PullRequestActivityCheck { + id: c.name.clone(), + name: c.name, + status: c.status, + }) + .collect(), + checks_complete: pr.checks_complete, + }) + } +} + +fn field(v: &Value, pointer: &str) -> String { + v.pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .into() +} + +fn bitbucket_inline(c: &PullRequestPendingComment) -> Value { + let mut inline = json!({"path":c.path}); + let addition = c.side == PullRequestDiffSide::Additions; + inline[if addition { "to" } else { "from" }] = c.end_line.into(); + if c.start_line != c.end_line { + inline[if addition { "start_to" } else { "start_from" }] = c.start_line.into(); + } + json!({"content":{"raw":c.body},"inline":inline}) +} + +fn gitlab_inline( + v: &Value, + diffs: &[Value], + c: &PullRequestPendingComment, + head: &str, +) -> Result { + let refs = &v["diff_refs"]; + ensure_review_head(&field(refs, "/head_sha"), head)?; + for key in ["base_sha", "start_sha", "head_sha"] { + validate_commit(&field(refs, &format!("/{key}")))?; + } + let addition = c.side == PullRequestDiffSide::Additions; + let diff = diffs + .iter() + .find(|d| d[if addition { "new_path" } else { "old_path" }].as_str() == Some(&c.path)) + .ok_or("Selected path is absent from this GitLab diff version")?; + if diff["too_large"] == true || diff["collapsed"] == true { + return Err("GitLab omitted this file's diff; comment on the provider website".into()); + } + let mut position = json!({"position_type":"text","base_sha":refs["base_sha"],"start_sha":refs["start_sha"],"head_sha":refs["head_sha"],"old_path":diff["old_path"],"new_path":diff["new_path"]}); + let patch = diff["diff"] + .as_str() + .ok_or("GitLab omitted the file patch needed for line coordinates")?; + let end = gitlab_line(patch, c.end_line, addition)?; + if let Some(old) = end.old { + position["old_line"] = old.into(); + } + if let Some(new) = end.new { + position["new_line"] = new.into(); + } + if c.start_line != c.end_line { + let start = gitlab_line(patch, c.start_line, addition)?; + if start.hunk != end.hunk { + return Err("Select a GitLab comment range within one diff hunk".into()); + } + let filename = if diff["deleted_file"] == true { + field(diff, "/old_path") + } else { + field(diff, "/new_path") + }; + let hash = sha1_smol::Sha1::from(filename.as_bytes()) + .digest() + .to_string(); + let coordinate = |line: &GitLabLine| json!({"line_code":format!("{hash}_{}_{}",line.old.unwrap_or(0),line.new.unwrap_or(0)),"type":if line.old.is_none() {"new"} else {"old"},"old_line":line.old,"new_line":line.new}); + position["line_range"] = json!({"start":coordinate(&start),"end":coordinate(&end)}); + } + Ok(json!({"body":c.body,"position":position})) +} + +struct GitLabLine { + old: Option, + new: Option, + hunk: usize, +} + +fn gitlab_line(patch: &str, wanted: u32, addition: bool) -> Result { + let (mut old, mut new, mut hunk) = (0_u32, 0_u32, 0); + for line in patch.lines() { + if line.starts_with("@@ ") { + let mut parts = line.split_whitespace().skip(1); + let parse = |part: Option<&str>| { + part.and_then(|p| p.get(1..)) + .and_then(|p| p.split(',').next()) + .and_then(|p| p.parse::().ok()) + .ok_or("Invalid GitLab diff hunk") + }; + old = parse(parts.next())?; + new = parse(parts.next())?; + hunk += 1; + } else if hunk > 0 { + let first = line.as_bytes().first().copied(); + let old_line = matches!(first, Some(b' ' | b'-')).then_some(old); + let new_line = matches!(first, Some(b' ' | b'+')).then_some(new); + if (if addition { new_line } else { old_line }) == Some(wanted) { + return Ok(GitLabLine { + old: old_line, + new: new_line, + hunk, + }); + } + if old_line.is_some() { + old = old.saturating_add(1); + } + if new_line.is_some() { + new = new.saturating_add(1); + } + } + } + Err("Selected line is absent from the current GitLab diff".into()) +} + +#[cfg(test)] +mod tests { + use super::super::transport::fixtures::FixtureApi; + use super::*; + const HEAD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BASE: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + fn repo(bb: bool) -> HostedRepo { + HostedRepo { + provider: if bb { "bitbucket" } else { "gitlab" }.into(), + host: if bb { "bitbucket.org" } else { "gitlab.com" }.into(), + namespace: "team".into(), + repo: "app".into(), + } + } + fn mr() -> Value { + json!({"iid":7,"title":"Rename","state":"opened","sha":HEAD,"author":{"id":1,"username":"author"},"diff_refs":{"base_sha":BASE,"start_sha":BASE,"head_sha":HEAD}}) + } + #[test] + fn gitlab_ready_handles_title_markers_and_checks_provider_result() { + for title in [ + "Draft: Rename", + "[Draft] Rename", + "(draft) Rename", + "WIP: Rename", + ] { + let mut current = mr(); + current["title"] = json!(title); + let api = FixtureApi::new(vec![ + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(current)), + ( + "PUT", + "projects/team%2Fapp/merge_requests/7", + Ok(json!({"draft":false})), + ), + ]); + repo(false).ready(&api, 7).unwrap(); + assert_eq!(api.writes.borrow()[0].1["title"], "Rename"); + } + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp/merge_requests/7", + Ok(mr()), + )]); + assert!(repo(false).ready(&api, 7).is_err()); + assert!(api.writes.borrow().is_empty()); + } + fn comment(side: PullRequestDiffSide, start: u32, end: u32) -> PullRequestPendingComment { + PullRequestPendingComment { + path: if side == PullRequestDiffSide::Additions { + "after.txt" + } else { + "before.txt" + } + .into(), + start_line: start, + end_line: end, + side, + body: "Review text".into(), + } + } + fn diffs() -> Vec { + vec![ + json!({"old_path":"before.txt","new_path":"after.txt","diff":"@@ -1,4 +1,5 @@\n same\n-old\n+new\n+another\n context\n tail\n"}), + ] + } + + #[test] + fn gitlab_rename_context_and_ranges_use_version_coordinates() { + let added = gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 2, 3), + HEAD, + ) + .unwrap(); + assert_eq!(added["position"]["old_path"], "before.txt"); + assert_eq!(added["position"]["new_path"], "after.txt"); + assert_eq!(added["position"]["head_sha"], HEAD); + assert!(added["position"].get("old_line").is_none()); + assert_eq!(added["position"]["line_range"]["start"]["new_line"], 2); + assert_eq!(added["position"]["line_range"]["end"]["new_line"], 3); + let context = gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 4, 4), + HEAD, + ) + .unwrap(); + assert_eq!(context["position"]["new_line"], 4); + assert_eq!(context["position"]["old_line"], 3); + let deleted = gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Deletions, 2, 2), + HEAD, + ) + .unwrap(); + assert_eq!(deleted["position"]["old_line"], 2); + assert!(deleted["position"].get("new_line").is_none()); + assert!(gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 99, 99), + HEAD + ) + .is_err()); + assert!(gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 2, 2), + BASE + ) + .is_err()); + } + #[test] + fn bitbucket_coordinates_do_not_conflate_left_and_right_ranges() { + let left = bitbucket_inline(&comment(PullRequestDiffSide::Deletions, 2, 4)); + assert_eq!( + left["inline"], + json!({"path":"before.txt","from":4,"start_from":2}) + ); + let right = bitbucket_inline(&comment(PullRequestDiffSide::Additions, 3, 3)); + assert_eq!(right["inline"], json!({"path":"after.txt","to":3})); + } + #[test] + fn stale_heads_and_terminal_requests_never_write() { + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp/merge_requests/7", + Ok(mr()), + )]); + assert!(repo(false) + .inline( + &api, + 7, + &comment(PullRequestDiffSide::Additions, 2, 2), + BASE + ) + .is_err()); + assert!(api.writes.borrow().is_empty()); + api.done(); + let api = FixtureApi::new(vec![( + "GET", + "repositories/team/app/pullrequests/7", + Ok(json!({"state":"OPEN","source":{"commit":{"hash":HEAD}}})), + )]); + assert!(repo(true) + .review(&api, 7, PullRequestReviewEvent::Approve, "", &[], BASE) + .is_err()); + assert!(api.writes.borrow().is_empty()); + api.done(); + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp/merge_requests/7", + Ok(json!({"state":"merged"})), + )]); + assert!(repo(false).add_comment(&api, 7, "hello").is_err()); + assert!(api.writes.borrow().is_empty()); + } + #[test] + fn gitlab_approval_pins_head_and_reports_partial_batch_failure() { + let api = FixtureApi::new(vec![ + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(mr())), + ("GET", "user", Ok(json!({"id":2}))), + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(mr())), + ( + "POST", + "projects/team%2Fapp/merge_requests/7/approve", + Ok(json!({})), + ), + ( + "GET", + "projects/team%2Fapp/merge_requests/7", + Err("head unavailable".into()), + ), + ]); + let error = repo(false) + .review( + &api, + 7, + PullRequestReviewEvent::Approve, + "summary", + &[], + HEAD, + ) + .unwrap_err(); + assert!(error.contains("1 review writes were confirmed")); + assert_eq!(api.writes.borrow()[0].1, json!({"sha":HEAD})); + api.done(); + } + #[test] + fn permission_denial_and_self_review_do_not_write() { + let api = FixtureApi::new(vec![ + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(mr())), + ("GET", "user", Ok(json!({"id":1}))), + ]); + assert!(repo(false) + .review(&api, 7, PullRequestReviewEvent::Approve, "", &[], HEAD) + .unwrap_err() + .contains("own")); + assert!(api.writes.borrow().is_empty()); + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp", + Ok(json!({"permissions":{"project_access":{"access_level":10}}})), + )]); + let caps = repo(false) + .permissions(&api, &mr(), &json!({"id":2})) + .unwrap(); + assert!(!caps.can_close); + assert!(caps.merge_strategies.is_empty()); + let api = FixtureApi::new(vec![("GET","user/workspaces/team/permissions/repositories?q=repository.full_name%3D%22team%2Fapp%22&pagelen=100",Err("HTTP 403".into()))]); + assert!(repo(true) + .permissions(&api, &json!({"state":"OPEN"}), &json!({"uuid":"me"})) + .is_err()); + api.done(); + } + #[test] + fn bitbucket_merge_is_unavailable_without_atomic_head_guard() { + let api = FixtureApi::new(vec![]); + assert!(repo(true) + .merge(&api, 7, PullRequestMergeStrategy::MergeCommit, HEAD) + .unwrap_err() + .contains("atomically")); + api.done(); + } + #[test] + fn nested_bitbucket_replies_and_gitlab_thread_ranges_survive_normalization() { + let mut pr = PullRequest { + id: 7, + source_commit: HEAD.into(), + ..Default::default() + }; + repo(true).discussions(&mut pr,&[ + json!({"id":1,"inline":{"path":"a","from":4,"start_from":2},"content":{"raw":"root"}}), + json!({"id":2,"parent":{"id":1},"content":{"raw":"reply"}}), + json!({"id":3,"parent":{"id":2},"content":{"raw":"nested reply"}}), + ]); + assert_eq!(pr.comment_count, 3); + assert_eq!(pr.review_threads[0].comments.len(), 3); + assert_eq!(pr.review_threads[0].id, "bitbucket:7:1"); + assert_eq!(pr.review_threads[0].start_line, 2); + let mut pr = PullRequest { + id: 7, + source_commit: HEAD.into(), + ..Default::default() + }; + repo(false).discussions(&mut pr,&[json!({"id":"thread123","notes":[{"id":4,"position":{"new_path":"after.txt","new_line":5,"head_sha":BASE,"line_range":{"start":{"new_line":3}}},"resolvable":true}]})]); + assert_eq!(pr.review_threads[0].start_line, 3); + assert!(pr.review_threads[0].is_outdated); + assert!(!pr.review_threads[0].can_reply); + assert!(repo(true).thread("gitlab:7:123").is_err()); + } +} diff --git a/crates/strand-tauri/src/pull_requests/pages.rs b/crates/strand-tauri/src/pull_requests/pages.rs new file mode 100644 index 00000000..683d6eba --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/pages.rs @@ -0,0 +1,613 @@ +//! Explicit, bounded GitHub connection pages. No traversal on inbox focus. +use super::*; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, +}; + +static READS: OnceLock>>> = OnceLock::new(); + +pub struct ReadGuard { + id: String, + pub cancelled: Arc, +} +impl ReadGuard { + pub fn new(id: &str) -> Result { + Uuid::parse_str(id).map_err(|_| "Invalid read request ID".to_string())?; + let cancelled = Arc::new(AtomicBool::new(false)); + let mut reads = READS + .get_or_init(Default::default) + .lock() + .map_err(|_| "Read lock failed")?; + if reads.contains_key(id) { + return Err("Read request already active".into()); + } + reads.insert(id.into(), cancelled.clone()); + Ok(Self { + id: id.into(), + cancelled, + }) + } +} +impl Drop for ReadGuard { + fn drop(&mut self) { + if let Ok(mut reads) = READS.get_or_init(Default::default).lock() { + reads.remove(&self.id); + } + } +} +pub fn cancel(id: &str) { + if let Ok(reads) = READS.get_or_init(Default::default).lock() { + if let Some(cancelled) = reads.get(id) { + cancelled.store(true, Ordering::Relaxed); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Comments, + Commits, + Reviews, + Threads, + Replies, + Checks, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cursor { + pub kind: Kind, + pub thread_id: Option, + pub cursor: Option, + pub total: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Page { + pub source_commit: String, + pub request: Cursor, + pub pending: Vec, + pub comments: Vec, + pub commits: Vec, + pub reviews: Vec, + pub review_threads: Vec, + pub checks: Vec, +} + +const INFO: &str = "totalCount pageInfo { hasNextPage endCursor }"; +const COMMENT: &str = "id body createdAt url author { login avatarUrl }"; +const REVIEW: &str = + "id body state submittedAt url commit { oid } viewerCanUpdate viewerDidAuthor author { login avatarUrl }"; +const CHECK: &str = "__typename ... on CheckRun { id databaseId name status conclusion } ... on StatusContext { id context state }"; +const COMMIT: &str = + "commit { oid messageHeadline committedDate url author { name avatarUrl user { login } } }"; + +fn connection(kind: Kind, after: &str) -> String { + let (name, fields) = match kind { + Kind::Comments | Kind::Replies => ("comments", COMMENT.to_string()), + Kind::Reviews => ("reviews", REVIEW.to_string()), + Kind::Commits => ("commits", COMMIT.to_string()), + Kind::Checks => ("contexts", CHECK.to_string()), + // Only the root comment per thread; replies have their own connection. + Kind::Threads => ("reviewThreads", format!("id isResolved isOutdated viewerCanReply viewerCanResolve viewerCanUnresolve path line startLine originalLine originalStartLine diffSide startDiffSide comments(first: 1) {{ {INFO} nodes {{ {COMMENT} }} }}")), + }; + let field = format!("{name}(first: 50, after: {after}) {{ {INFO} nodes {{ {fields} }} }}"); + if kind == Kind::Checks { + format!("statusCheckRollup {{ {field} }}") + } else { + field + } +} + +#[cfg(test)] +pub fn review_query_contract() -> String { + format!( + "viewerCanUpdate {} {}", + connection(Kind::Threads, "null"), + connection(Kind::Reviews, "null") + ) +} + +/// Background snapshots keep bodies/patches out, but never truncate checks. +pub(super) fn activity_checks( + cwd: &GitHubContext<'_>, + owner: &str, + repo: &str, + id: u64, + value: &mut Value, +) -> Result<()> { + if value.get("statusCheckRollup") == Some(&Value::Null) { + return Ok(()); + } + let mut cursor = next_cursor(&value["statusCheckRollup"]["contexts"], None)?; + let head = text(value.get("headRefOid")).ok_or("Missing GitHub head")?; + let mut seen = std::collections::HashSet::new(); + while let Some(after) = cursor { + if !seen.insert(after.clone()) { + return Err("Repeated GitHub check cursor".into()); + } + let field = connection(Kind::Checks, "$cursor"); + let query_text = format!("query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {{ repository(owner: $owner, name: $repo) {{ pullRequest(number: $number) {{ headRefOid {field} }} }} }}"); + let next = query( + cwd, + &query_text, + serde_json::json!({"owner":owner,"repo":repo,"number":id,"cursor":after}), + None, + )?; + let pr = &next["data"]["repository"]["pullRequest"]; + ensure_review_head(pr["headRefOid"].as_str().unwrap_or_default(), &head)?; + let contexts = &pr["statusCheckRollup"]["contexts"]; + cursor = next_cursor(contexts, Some(&after))?; + value["statusCheckRollup"]["contexts"]["nodes"] + .as_array_mut() + .ok_or("Missing check nodes")? + .extend(array(contexts, "nodes").iter().cloned()); + value["statusCheckRollup"]["contexts"]["pageInfo"] = contexts["pageInfo"].clone(); + } + if let Some(nodes) = value["statusCheckRollup"]["contexts"]["nodes"].as_array_mut() { + let mut ids = std::collections::HashSet::new(); + nodes.retain(|c| parse_github_activity_check(c).is_some_and(|c| ids.insert(c.id))); + } + Ok(()) +} + +pub(super) fn query( + cwd: &GitHubContext<'_>, + query: &str, + variables: Value, + cancelled: Option<&AtomicBool>, +) -> Result { + let input = + serde_json::to_vec(&github_graphql_payload(query, variables)).map_err(|e| e.to_string())?; + let output = run_command_input_cancellable( + cwd.path, + "gh", + &["api", "graphql", "--hostname", cwd.host, "--method", "POST", "--input", "-"], + &[("GH_PROMPT_DISABLED", "1"), ("GH_HOST", cwd.host)], + Some(&input), + cancelled, + )?; + let value: Value = + serde_json::from_slice(&output).map_err(|e| format!("Invalid GitHub page: {e}"))?; + if let Some(errors) = value + .get("errors") + .and_then(Value::as_array) + .filter(|v| !v.is_empty()) + { + return Err(format!( + "GitHub page failed: {}", + errors + .iter() + .filter_map(|e| e.get("message").and_then(Value::as_str)) + .collect::>() + .join("; ") + )); + } + Ok(value) +} + +pub fn inbox(path: &str, cursor: Option<&str>, request_id: &str) -> Result { + let guard = ReadGuard::new(request_id)?; + let (remote, host) = host_for_path(path)?; + let HostRepo::GitHub { host, owner, repo } = host else { + if cursor.is_some() { + return Err("Inbox pagination is unavailable for this provider".into()); + } + return list(path); + }; + let value = query( + &GitHubContext { path, host: &host }, + r#"query($owner: String!, $repo: String!, $cursor: String) { + viewer { login } + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { + totalCount pageInfo { hasNextPage endCursor } + nodes { number title state isDraft author { login } headRefName headRefOid baseRefName createdAt updatedAt closedAt mergedAt url reviewDecision additions deletions changedFiles } + } + } + }"#, + serde_json::json!({"owner":owner,"repo":repo,"cursor":cursor}), + Some(&guard.cancelled), + )?; + let connection = value + .pointer("/data/repository/pullRequests") + .ok_or("Missing GitHub inbox")?; + let next_cursor = next_cursor(connection, cursor)?; + let viewer = text(value.pointer("/data/viewer/login")); + let mut seen = std::collections::HashSet::new(); + Ok(PullRequestList { + repository: PullRequestRepository { + provider: PullRequestProvider::GitHub, + remote, + label: GitHubContext { path, host: &host }.slug(&owner, &repo), + viewer: viewer.clone(), + }, + pull_requests: array(connection, "nodes") + .iter() + .filter_map(|v| parse_github_pr(v, viewer.as_deref())) + .filter(|pr| seen.insert(pr.id)) + .collect(), + next_cursor, + total_count: connection.get("totalCount").and_then(Value::as_u64), + }) +} + +fn next_cursor(value: &Value, previous: Option<&str>) -> Result> { + match value + .pointer("/pageInfo/hasNextPage") + .and_then(Value::as_bool) + { + Some(false) => Ok(None), + Some(true) => text(value.pointer("/pageInfo/endCursor")) + .filter(|cursor| !cursor.is_empty() && Some(cursor.as_str()) != previous) + .map(Some) + .ok_or_else(|| "GitHub returned a missing or repeated cursor; refresh to retry".into()), + None => Err("GitHub did not report whether this connection is complete".into()), + } +} + +const KINDS: [Kind; 5] = [ + Kind::Comments, + Kind::Commits, + Kind::Reviews, + Kind::Threads, + Kind::Checks, +]; + +pub(super) fn initial(cwd: &GitHubContext<'_>, owner: &str, repo: &str, pr: &mut PullRequest) { + let fields = KINDS + .iter() + .map(|kind| connection(*kind, "null")) + .collect::>() + .join(" "); + let fields = format!("{} {fields}", completion::GITHUB_FIELDS); + let query_text = format!("query($owner: String!, $repo: String!, $number: Int!) {{ repository(owner: $owner, name: $repo) {{ pullRequest(number: $number) {{ headRefOid viewerCanUpdate {fields} }} }} }}"); + let result = query( + cwd, + &query_text, + serde_json::json!({"owner":owner,"repo":repo,"number":pr.id}), + None, + ); + if let Ok(value) = &result { + pr.can_mark_ready = pr.is_draft && parse_github_can_mark_ready(value); + pr.completion = Some(completion::github(&value["data"]["repository"]["pullRequest"])); + } + for kind in KINDS { + let request = Cursor { + kind, + thread_id: None, + cursor: None, + total: None, + error: None, + }; + let page = result + .as_ref() + .map_err(Clone::clone) + .and_then(|value| parse_page(value, request.clone(), &pr.source_commit)); + match page { + Ok(page) => { + pr.comments.extend(page.comments); + pr.commits.extend(page.commits); + pr.reviews.extend(page.reviews); + pr.review_threads.extend(page.review_threads); + pr.checks.extend(page.checks); + pr.data_pages.extend(page.pending); + } + Err(error) => pr.data_pages.push(Cursor { + error: Some(error), + ..request + }), + } + } + pr.comments.extend( + pr.review_threads + .iter() + .flat_map(|t| t.comments.iter().cloned()), + ); + let mut seen = std::collections::HashSet::new(); + pr.comments.retain(|c| seen.insert(c.id.clone())); + pr.comment_count = pr.comments.len(); + pr.commit_count = pr.commits.len(); + pr.checks_complete = !pr.data_pages.iter().any(|p| p.kind == Kind::Checks); +} + +pub fn read( + path: &str, + id: u64, + expected_head: &str, + request: Cursor, + request_id: &str, +) -> Result { + let guard = ReadGuard::new(request_id)?; + read_cancellable(path, id, expected_head, request, &guard.cancelled) +} + +pub fn read_cancellable(path: &str, id: u64, expected_head: &str, request: Cursor, cancelled: &AtomicBool) -> Result { + validate_commit(expected_head)?; + let (_, host) = host_for_path(path)?; + let HostRepo::GitHub { host, owner, repo } = host else { + return Err("Connection pages are unavailable for this provider".into()); + }; + let field = connection(request.kind, "$cursor"); + let selection = if request.kind == Kind::Replies { + validate_thread_id(request.thread_id.as_deref().unwrap_or_default())?; + format!("node(id: $threadId) {{ ... on PullRequestReviewThread {{ pullRequest {{ number repository {{ nameWithOwner }} }} {field} }} }}") + } else { + String::new() + }; + let thread_variable = if request.kind == Kind::Replies { + ", $threadId: ID!" + } else { + "" + }; + let pr_field = if request.kind == Kind::Replies { + "" + } else { + &field + }; + let query_text = format!("query($owner: String!, $repo: String!, $number: Int!, $cursor: String{thread_variable}) {{ repository(owner: $owner, name: $repo) {{ pullRequest(number: $number) {{ headRefOid viewerCanUpdate {pr_field} }} }} {selection} }}"); + let value = query( + &GitHubContext { path, host: &host }, + &query_text, + serde_json::json!({"owner":owner,"repo":repo,"number":id,"cursor":request.cursor,"threadId":request.thread_id}), + Some(cancelled), + )?; + if request.kind == Kind::Replies + && (value + .pointer("/data/node/pullRequest/number") + .and_then(Value::as_u64) + != Some(id) + || text(value.pointer("/data/node/pullRequest/repository/nameWithOwner")).as_deref() + != Some(format!("{owner}/{repo}").as_str())) + { + return Err("Thread does not belong to this pull request".into()); + } + let mut page = parse_page(&value, request, expected_head)?; + scope_page(&mut page, &GitHubContext { path, host: &host }, &owner, &repo); + Ok(page) +} + +fn scope_page(page: &mut Page, context: &GitHubContext<'_>, owner: &str, repo: &str) { + for commit in &mut page.commits { + commit.url = Some(format!("https://{}/{owner}/{repo}/commit/{}", context.host, commit.id)); + context.scope_avatar(&mut commit.avatar_url); + } + for comment in &mut page.comments { context.scope_avatar(&mut comment.avatar_url); } + for review in &mut page.reviews { context.scope_avatar(&mut review.avatar_url); } + for thread in &mut page.review_threads { + for comment in &mut thread.comments { context.scope_avatar(&mut comment.avatar_url); } + } +} + +fn parse_page(value: &Value, request: Cursor, expected_head: &str) -> Result { + let pr = value + .pointer("/data/repository/pullRequest") + .ok_or("Missing GitHub pull request")?; + let head = text(pr.get("headRefOid")).ok_or("Missing GitHub head")?; + ensure_review_head(&head, expected_head)?; + let key = match request.kind { + Kind::Comments => "/comments", + Kind::Commits => "/commits", + Kind::Reviews => "/reviews", + Kind::Threads => "/reviewThreads", + Kind::Checks => "/statusCheckRollup/contexts", + Kind::Replies => "", + }; + let empty_checks = + serde_json::json!({"nodes":[],"pageInfo":{"hasNextPage":false},"totalCount":0}); + let connection = if request.kind == Kind::Replies { + value.pointer("/data/node/comments") + } else if request.kind == Kind::Checks && pr.get("statusCheckRollup") == Some(&Value::Null) { + Some(&empty_checks) + } else { + pr.pointer(key) + } + .ok_or("Missing GitHub connection; loaded data is incomplete")?; + let mut pending = Vec::new(); + if let Some(cursor) = next_cursor(connection, request.cursor.as_deref())? { + pending.push(Cursor { + cursor: Some(cursor), + total: connection.get("totalCount").and_then(Value::as_u64), + error: None, + ..request.clone() + }); + } + let mut page = Page { + source_commit: head, + request: request.clone(), + pending, + comments: vec![], + commits: vec![], + reviews: vec![], + review_threads: vec![], + checks: vec![], + }; + match request.kind { + Kind::Reviews => page.reviews = parse_github_reviews(value), + Kind::Threads => { + page.review_threads = parse_github_review_threads(value); + for thread in array(connection, "nodes") { + if let Some(cursor) = next_cursor(&thread["comments"], None)? { + page.pending.push(Cursor { + kind: Kind::Replies, + thread_id: text(thread.get("id")), + cursor: Some(cursor), + total: thread + .pointer("/comments/totalCount") + .and_then(Value::as_u64), + error: None, + }); + } + } + } + Kind::Comments | Kind::Replies => { + let mock = serde_json::json!({"number":1,"comments":connection["nodes"]}); + page.comments = parse_github_pr(&mock, None).unwrap().comments; + } + Kind::Checks => { + page.checks = array(connection, "nodes") + .iter() + .filter_map(parse_github_activity_check) + .map(|c| PullRequestCheck { + id: c.id, + name: c.name, + status: c.status, + }) + .collect() + } + Kind::Commits => { + page.commits = array(connection, "nodes") + .iter() + .filter_map(|node| { + let c = &node["commit"]; + Some(PullRequestCommit { + id: text(c.get("oid"))?, + title: text(c.get("messageHeadline")).unwrap_or_default(), + author: text(c.pointer("/author/name")).unwrap_or_else(|| "unknown".into()), + avatar_url: text(c.pointer("/author/avatarUrl")), + committed_at: text(c.get("committedDate")).unwrap_or_default(), + url: text(c.get("url")), + }) + }) + .collect() + } + } + Ok(page) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn enterprise_commit_pages_keep_links_and_avatars_on_the_selected_host() { + let head = "a".repeat(40); + let value = serde_json::json!({"data":{"repository":{"pullRequest":{ + "headRefOid":head,"commits":{"nodes":[{"commit":{"oid":head, + "author":{"name":"Reviewer","avatarUrl":"https://github.com/reviewer.png"}}}], + "pageInfo":{"hasNextPage":false}} + }}}}); + let request = Cursor { kind: Kind::Commits, thread_id: None, cursor: None, total: None, error: None }; + let mut page = parse_page(&value, request, &head).unwrap(); + scope_page(&mut page, &GitHubContext { path: ".", host: "git.example:8443" }, "team", "app"); + assert_eq!(page.commits[0].url.as_deref(), Some(format!("https://git.example:8443/team/app/commit/{head}").as_str())); + assert_eq!(page.commits[0].avatar_url.as_deref(), Some("https://git.example:8443/reviewer.png")); + } + + #[test] + fn missing_or_repeated_cursor_is_an_error() { + assert!(next_cursor(&serde_json::json!({}), None).is_err()); + assert!(next_cursor( + &serde_json::json!({"pageInfo":{"hasNextPage":true,"endCursor":"same"}}), + Some("same") + ) + .is_err()); + assert_eq!( + next_cursor(&serde_json::json!({"pageInfo":{"hasNextPage":false}}), None).unwrap(), + None + ); + } + #[test] + fn cancellation_is_scoped_and_removed() { + let id = Uuid::new_v4().to_string(); + let guard = ReadGuard::new(&id).unwrap(); + assert!(ReadGuard::new(&id).is_err()); + cancel(&id); + assert!(guard.cancelled.load(Ordering::Relaxed)); + drop(guard); + assert!(!ReadGuard::new(&id) + .unwrap() + .cancelled + .load(Ordering::Relaxed)); + } + #[test] + fn thread_pages_keep_nested_reads_shallow() { + let query = connection(Kind::Threads, "$cursor"); + assert!(query.contains("comments(first: 1)")); + assert!(query.contains("after: $cursor")); + } + + #[test] + fn all_connections_traverse_101_entries_and_reject_force_push() { + let head = "a".repeat(40); + for kind in [ + Kind::Comments, + Kind::Reviews, + Kind::Threads, + Kind::Replies, + Kind::Checks, + Kind::Commits, + ] { + let mut count = 0; + let mut cursor = None; + for (start, end) in [(0, 50), (50, 100), (100, 101)] { + let nodes = (start..end).map(|i| serde_json::json!({ + "id":format!("node-{i}"),"body":"feedback","state":"APPROVED","author":{"login":"reviewer"}, + "path":"file.rs","line":i+1,"diffSide":"RIGHT", "name":format!("check-{i}"),"status":"SUCCESS", + "comments":{"nodes":[{"id":format!("comment-{i}"),"body":"feedback"}],"pageInfo":{"hasNextPage":false}}, + "commit":{"oid":format!("{i:040x}"),"messageHeadline":"Commit"} + })).collect::>(); + let connection = serde_json::json!({"nodes":nodes,"totalCount":101,"pageInfo":{"hasNextPage":end<101,"endCursor":end.to_string()}}); + let mut value = + serde_json::json!({"data":{"repository":{"pullRequest":{"headRefOid":head}}}}); + let pr = &mut value["data"]["repository"]["pullRequest"]; + match kind { + Kind::Comments => pr["comments"] = connection, + Kind::Commits => pr["commits"] = connection, + Kind::Reviews => pr["reviews"] = connection, + Kind::Threads => pr["reviewThreads"] = connection, + Kind::Checks => { + pr["statusCheckRollup"] = serde_json::json!({"contexts":connection}) + } + Kind::Replies => { + value["data"]["node"] = serde_json::json!({"comments":connection}) + } + } + let request = Cursor { + kind, + thread_id: None, + cursor, + total: None, + error: None, + }; + assert!(parse_page(&value, request.clone(), &"b".repeat(40)).is_err()); + let page = parse_page(&value, request, &head).unwrap(); + count += page.comments.len() + + page.commits.len() + + page.reviews.len() + + page.review_threads.len() + + page.checks.len(); + cursor = page.pending.first().and_then(|p| p.cursor.clone()); + } + assert_eq!(count, 101, "{kind:?}"); + assert!(cursor.is_none()); + } + } + + #[test] + fn cancellation_terminates_an_active_read() { + let cancelled = Arc::new(AtomicBool::new(false)); + let signal = cancelled.clone(); + let worker = thread::spawn(move || { + thread::sleep(Duration::from_millis(250)); + signal.store(true, Ordering::Relaxed); + }); + let start = Instant::now(); + #[cfg(windows)] + let result = run_command_input_cancellable( + ".", + "powershell", + &["-NoProfile", "-Command", "Start-Sleep -Seconds 20"], + &[], + None, + Some(&cancelled), + ); + #[cfg(not(windows))] + let result = + run_command_input_cancellable(".", "sleep", &["20"], &[], None, Some(&cancelled)); + worker.join().unwrap(); + assert!(result.unwrap_err().contains("cancelled")); + assert!(start.elapsed() < Duration::from_secs(5)); + } +} diff --git a/crates/strand-tauri/src/pull_requests/publish.rs b/crates/strand-tauri/src/pull_requests/publish.rs new file mode 100644 index 00000000..acd15c12 --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/publish.rs @@ -0,0 +1,760 @@ +//! Repository publishing is a resumable sequence, never an implicit push. +use super::transport::{pages, segment, validate_host, Api, Client}; +use super::{run_command, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Mutex; +use strand_core::Repo; + +// Serialize journal transitions; PR reads and ordinary Git operations do not use this lock. +static PUBLISH_WRITE: Mutex<()> = Mutex::new(()); +const JOURNAL: &str = "strand.publish-state"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Destination { + pub id: String, + pub label: String, + pub kind: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishAccount { + pub account: String, + pub account_id: String, + pub destinations: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishRequest { + pub provider: String, + pub host: String, + pub account_id: String, + pub destination: String, + pub name: String, + pub visibility: String, + pub remote: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishState { + pub id: String, + pub request: PublishRequest, + pub account: String, + pub destination: Destination, + pub url: String, + pub clone_url: String, + pub branch: String, + pub head: String, + pub stage: String, + pub error: Option, +} + +fn check_provider(provider: &str, host: &str) -> Result<()> { + validate_host(host)?; + if !matches!(provider, "github" | "gitlab" | "bitbucket") { + return Err("Choose GitHub, GitLab, or Bitbucket Cloud".into()); + } + if provider == "bitbucket" && host != "bitbucket.org" { + return Err("Bitbucket Server is outside the Cloud adapter scope".into()); + } + Ok(()) +} + +pub fn accounts(path: &str, provider: &str, host: &str) -> Result { + check_provider(provider, host)?; + Repo::discover(path).map_err(|e| e.to_string())?; + account( + &Client { + cwd: path, + provider, + host, + }, + provider, + ) +} + +fn account(api: &impl Api, provider: &str) -> Result { + let viewer = api.json("GET", "user", None)?; + let account_id = viewer + .get(if provider == "bitbucket" { + "uuid" + } else { + "id" + }) + .filter(|v| !v.is_null()) + .ok_or("Provider returned no account identity")? + .to_string(); + let account = string( + &viewer, + if provider == "github" { + "/login" + } else if provider == "gitlab" { + "/username" + } else { + "/display_name" + }, + )?; + let destinations = match provider { + "github" => { + let mut rows = vec![Destination { + id: account.clone(), + label: account.clone(), + kind: "account".into(), + }]; + rows.extend( + pages(api, "user/orgs", false)? + .iter() + .map(|o| { + let login = string(o, "/login")?; + Ok(Destination { + id: login.clone(), + label: login, + kind: "organization".into(), + }) + }) + .collect::>>()?, + ); + rows + } + "gitlab" => pages(api, "namespaces", false)? + .iter() + .map(|n| { + Ok(Destination { + id: n["id"].as_u64().ok_or("Invalid namespace ID")?.to_string(), + label: string(n, "/full_path")?, + kind: string(n, "/kind")?, + }) + }) + .collect::>>()?, + _ => pages(api, "user/workspaces", true)? + .iter() + .map(|n| { + Ok(Destination { + id: string(n, "/workspace/slug")?, + label: string(n, "/workspace/slug")?, + kind: "workspace".into(), + }) + }) + .collect::>>()?, + }; + Ok(PublishAccount { + account, + account_id, + destinations, + }) +} + +pub fn state(path: &str) -> Result> { + Repo::discover(path).map_err(|e| e.to_string())?; + let Ok(bytes) = run_command(path, "git", &["config", "--local", "--get", JOURNAL], &[]) else { + return Ok(None); + }; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "Publish recovery record is invalid".into()) +} +fn save(path: &str, state: &PublishState) -> Result<()> { + let value = serde_json::to_string(state).map_err(|e| e.to_string())?; + run_command(path, "git", &["config", "--local", JOURNAL, &value], &[])?; + Ok(()) +} + +pub fn forget(path: &str) -> Result<()> { + let _lock = PUBLISH_WRITE + .lock() + .map_err(|_| "Publish state lock failed")?; + if state(path)?.is_some() { + run_command( + path, + "git", + &["config", "--local", "--unset-all", JOURNAL], + &[], + )?; + } + Ok(()) +} + +fn validate_request(request: &PublishRequest) -> Result<()> { + check_provider(&request.provider, &request.host)?; + if !matches!(request.visibility.as_str(), "private" | "public") { + return Err("Choose private or public visibility".into()); + } + if request.name.is_empty() + || request.name.len() > 100 + || request.name.starts_with(['-', '.']) + || request.name.ends_with(".git") + || !request + .name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + { + return Err("Use a repository name of 1–100 letters, digits, hyphens, underscores or dots; do not start with a dot or hyphen, or end with .git".into()); + } + if request.provider == "bitbucket" && request.name != request.name.to_lowercase() { + return Err("Bitbucket repository slugs must be lowercase".into()); + } + if request.remote.is_empty() + || request.remote.len() > 100 + || request.remote.starts_with(['-', '.']) + || !request + .remote + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) + { + return Err("Use a remote name with letters, digits, hyphens or underscores".into()); + } + Ok(()) +} + +pub fn preview(path: &str, request: PublishRequest) -> Result { + let _lock = PUBLISH_WRITE + .lock() + .map_err(|_| "Publish state lock failed")?; + validate_request(&request)?; + if state(path)?.is_some_and(|s| !matches!(s.stage.as_str(), "review" | "pushed")) { + return Err("Resume or dismiss the existing publish recovery record first".into()); + } + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + if repo + .refs() + .map_err(|e| e.to_string())? + .remotes + .iter() + .any(|r| r.name == request.remote) + { + return Err("That remote already exists. Choose a new remote name".into()); + } + let account = accounts(path, &request.provider, &request.host)?; + if account.account_id != request.account_id { + return Err("The signed-in account changed; reload destinations".into()); + } + let destination = account + .destinations + .into_iter() + .find(|d| d.id == request.destination) + .ok_or("Destination is no longer available to this account")?; + let meta = repo.meta().map_err(|e| e.to_string())?; + if meta.detached { + return Err("Switch to a local branch before publishing".into()); + } + let head = run_command(path, "git", &["rev-parse", "--verify", "HEAD"], &[]) + .ok() + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default() + .trim() + .to_string(); + let url = format!( + "https://{}/{}/{}", + request.host, + destination + .label + .split('/') + .map(segment) + .collect::>() + .join("/"), + segment(&request.name) + ); + let state = PublishState { + id: uuid::Uuid::new_v4().to_string(), + request, + account: account.account, + destination, + clone_url: format!("{url}.git"), + url, + branch: meta.branch, + head, + stage: "review".into(), + error: None, + }; + save(path, &state)?; + Ok(state) +} + +fn existing_endpoint(s: &PublishState) -> String { + match s.request.provider.as_str() { + "github" => format!( + "repos/{}/{}", + segment(&s.destination.label), + segment(&s.request.name) + ), + "gitlab" => format!( + "projects/{}", + segment(&format!("{}/{}", s.destination.label, s.request.name)) + ), + _ => format!( + "repositories/{}/{}", + segment(&s.destination.label), + segment(&s.request.name) + ), + } +} +fn create_payload(s: &PublishState) -> (String, Value) { + let r = &s.request; + match r.provider.as_str() { + "github" => ( + if s.destination.kind == "account" { + "user/repos".into() + } else { + format!("orgs/{}/repos", segment(&s.destination.label)) + }, + json!({"name":r.name,"private":r.visibility == "private","auto_init":false}), + ), + "gitlab" => ( + "projects".into(), + json!({"name":r.name,"path":r.name,"namespace_id":s.destination.id.parse::().unwrap_or(0),"visibility":r.visibility,"initialize_with_readme":false}), + ), + _ => ( + existing_endpoint(s), + json!({"scm":"git","is_private":r.visibility == "private","name":r.name}), + ), + } +} + +fn validate_created(s: &PublishState, v: &Value) -> Result<()> { + let full = string( + v, + match s.request.provider.as_str() { + "github" => "/full_name", + "gitlab" => "/path_with_namespace", + _ => "/full_name", + }, + )?; + if !full.eq_ignore_ascii_case(&format!("{}/{}", s.destination.label, s.request.name)) { + return Err("Provider returned a different repository destination. Inspect it on the provider website".into()); + } + let private = match s.request.provider.as_str() { + "gitlab" => v["visibility"] == "private", + "bitbucket" => v["is_private"] == true, + _ => v["private"] == true, + }; + if private != (s.request.visibility == "private") { + return Err("Repository visibility differs from the reviewed choice. Inspect it on the provider website".into()); + } + Ok(()) +} + +/// `check` is read-only recovery after an uncertain create. The next explicit +/// `attach` action is the user's decision to use the inspected destination. +pub fn advance(path: &str, id: &str, action: &str) -> Result { + let _lock = PUBLISH_WRITE + .lock() + .map_err(|_| "Publish state lock failed")?; + let s = state(path)?.ok_or("No publish recovery record")?; + let request = s.request.clone(); + advance_using( + path, + id, + action, + s, + &Client { + cwd: path, + provider: &request.provider, + host: &request.host, + }, + ) +} + +fn advance_using( + path: &str, + id: &str, + action: &str, + mut s: PublishState, + api: &impl Api, +) -> Result { + if s.id != id { + return Err("The publish review changed. Reopen the dialog".into()); + } + validate_request(&s.request)?; + let destination_url = format!( + "https://{}/{}/{}", + s.request.host, + s.destination + .label + .split('/') + .map(segment) + .collect::>() + .join("/"), + segment(&s.request.name) + ); + if s.url != destination_url || s.clone_url != format!("{destination_url}.git") { + return Err( + "The saved publish destination changed. Dismiss recovery and review it again".into(), + ); + } + s.error = None; + let result = match action { + "create" if s.stage == "review" => { + let account = account(api, &s.request.provider)?; + if account.account_id != s.request.account_id { + return Err("The signed-in account changed; review the destination again".into()); + } + // Persist before the POST: even a timeout/process exit must not + // invite a blind duplicate creation on the next launch. + s.stage = "uncertain".into(); + save(path, &s)?; + let (endpoint, payload) = create_payload(&s); + api.json("POST", &endpoint, Some(&payload)) + .and_then(|v| validate_created(&s, &v)) + .map(|_| { + s.stage = "created".into(); + }) + } + "check" if s.stage == "uncertain" => api + .json("GET", &existing_endpoint(&s), None) + .and_then(|v| validate_created(&s, &v)) + .map(|_| { + s.stage = "created".into(); + }), + "attach" if s.stage == "created" => attach(path, &s).map(|_| { + s.stage = "remote_ready".into(); + }), + "push" if s.stage == "remote_ready" => push(path, &s).map(|_| { + s.stage = "pushed".into(); + }), + _ => return Err("That publish step is not available; refresh the recovery state".into()), + }; + if let Err(error) = result { + s.error = Some(error); + } + save(path, &s)?; + Ok(s) +} + +fn attach(path: &str, s: &PublishState) -> Result<()> { + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + let refs = repo.refs().map_err(|e| e.to_string())?; + if let Some(remote) = refs.remotes.iter().find(|r| r.name == s.request.remote) { + if repo + .configured_remote_url(&remote.name) + .map_err(|e| e.to_string())? + .as_deref() + != Some(&s.clone_url) + || remote.push_url.is_some() + { + return Err("The remote now exists with another destination. Resolve it in Manage remotes before retrying".into()); + } + } else { + repo.add_remote(&s.request.remote, &s.clone_url, None) + .map_err(|e| e.to_string())?; + } + if s.request.host != "github.com" && s.request.provider == "github" + || s.request.provider == "gitlab" + { + super::set_hosting_provider(path, &s.request.remote, &s.request.provider)?; + } + Ok(()) +} + +fn push(path: &str, s: &PublishState) -> Result<()> { + if s.head.is_empty() { + return Err("This repository had no commit at review time. Create a commit and use the ordinary Push action".into()); + } + super::validate_commit(&s.head)?; + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + let remote = repo + .refs() + .map_err(|e| e.to_string())? + .remotes + .into_iter() + .find(|r| r.name == s.request.remote) + .ok_or("Remote no longer exists")?; + if repo + .configured_remote_url(&remote.name) + .map_err(|e| e.to_string())? + .as_deref() + != Some(&s.clone_url) + || remote.push_url.is_some() + { + return Err("Remote destination changed; initial push stopped".into()); + } + let effective = run_command( + path, + "git", + &["remote", "get-url", "--push", "--all", &remote.name], + &[], + )?; + if String::from_utf8_lossy(&effective).trim() != s.clone_url { + return Err("Git URL rewriting changes the reviewed push destination; inspect remote configuration before pushing".into()); + } + let meta = repo.meta().map_err(|e| e.to_string())?; + if meta.detached || meta.branch != s.branch { + return Err("The checked-out branch changed; use the ordinary Push action to review a new destination".into()); + } + run_command( + path, + "git", + &["check-ref-format", &format!("refs/heads/{}", s.branch)], + &[], + )?; + // Pin the refspec to the reviewed object: a concurrent local commit cannot + // silently expand the first publication. Never force an existing branch. + run_command( + path, + "git", + &[ + "push", + "--no-follow-tags", + "--recurse-submodules=no", + "--", + &s.request.remote, + &format!("{}:refs/heads/{}", s.head, s.branch), + ], + &[("GIT_TERMINAL_PROMPT", "0")], + )?; + // Do not overwrite an existing upstream configuration. + let upstream = run_command( + path, + "git", + &["config", "--get", &format!("branch.{}.remote", s.branch)], + &[], + ); + if upstream.is_err() { + run_command(path, "git", &["branch", &format!("--set-upstream-to={}/{}", s.request.remote, s.branch), &s.branch], &[]) + .map_err(|e| format!("Push succeeded, but upstream setup failed. Retry is safe (same reviewed commit): {e}"))?; + } + Ok(()) +} + +fn string(v: &Value, pointer: &str) -> Result { + v.pointer(pointer) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("Provider returned no {pointer}")) +} + +#[cfg(test)] +mod tests { + use super::super::transport::fixtures::FixtureApi; + use super::*; + fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_str().unwrap(); + run_command(path, "git", &["init", "-q", "-b", "main"], &[]).unwrap(); + run_command( + path, + "git", + &[ + "config", + "core.hooksPath", + dir.path().join("empty-hooks").to_str().unwrap(), + ], + &[], + ) + .unwrap(); + run_command( + path, + "git", + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "initial", + ], + &[], + ) + .unwrap(); + dir + } + fn plan() -> PublishState { + PublishState { + id: "fixture-plan".into(), + request: PublishRequest { + provider: "github".into(), + host: "github.com".into(), + account_id: "1".into(), + destination: "me".into(), + name: "app".into(), + visibility: "private".into(), + remote: "publish".into(), + }, + account: "me".into(), + destination: Destination { + id: "me".into(), + label: "me".into(), + kind: "account".into(), + }, + url: "https://github.com/me/app".into(), + clone_url: "https://github.com/me/app.git".into(), + branch: "main".into(), + head: "a".repeat(40), + stage: "review".into(), + error: None, + } + } + #[test] + fn validates_names_visibility_cloud_scope_and_provider_creation_payloads() { + let mut s = plan(); + assert_eq!( + create_payload(&s), + ( + "user/repos".into(), + json!({"name":"app","private":true,"auto_init":false}) + ) + ); + s.destination.kind = "organization".into(); + assert_eq!(create_payload(&s).0, "orgs/me/repos"); + s.request.provider = "gitlab".into(); + s.destination.id = "123".into(); + assert_eq!(create_payload(&s).1["namespace_id"], 123); + assert_eq!(create_payload(&s).1["initialize_with_readme"], false); + s.request.provider = "bitbucket".into(); + s.request.host = "bitbucket.org".into(); + assert_eq!(create_payload(&s).0, "repositories/me/app"); + assert_eq!(create_payload(&s).1["is_private"], true); + for name in ["../escape", "-option", "has space", "repo.git", "UpperCase"] { + s.request.name = name.into(); + assert!(validate_request(&s.request).is_err()); + } + s.request.name = "app".into(); + s.request.host = "server.example".into(); + assert!(validate_request(&s.request).is_err()); + s = plan(); + assert!(validate_created(&s, &json!({"full_name":"other/app","private":true})).is_err()); + assert!(validate_created(&s, &json!({"full_name":"me/app","private":false})).is_err()); + } + #[test] + fn failed_create_is_journaled_and_recovery_never_reposts() { + let dir = fixture(); + let path = dir.path().to_str().unwrap(); + let api = FixtureApi::new(vec![ + ("GET", "user", Ok(json!({"id":1,"login":"me"}))), + ("GET", "user/orgs?per_page=100", Ok(json!([]))), + ("POST", "user/repos", Err("HTTP 403 or timeout".into())), + ]); + let s = advance_using(path, "fixture-plan", "create", plan(), &api).unwrap(); + assert_eq!(s.stage, "uncertain"); + assert!(s.error.unwrap().contains("403")); + assert_eq!(state(path).unwrap().unwrap().stage, "uncertain"); + assert!(Repo::discover(path) + .unwrap() + .refs() + .unwrap() + .remotes + .is_empty()); + api.done(); + let api = FixtureApi::new(vec![( + "GET", + "repos/me/app", + Ok(json!({"full_name":"me/app","private":true})), + )]); + let s = advance_using( + path, + "fixture-plan", + "check", + state(path).unwrap().unwrap(), + &api, + ) + .unwrap(); + assert_eq!(s.stage, "created"); + assert!(api.writes.borrow().is_empty()); + api.done(); + let api = FixtureApi::new(vec![]); + assert!(advance_using(path, "fixture-plan", "create", s, &api).is_err()); + api.done(); + } + #[test] + fn changed_account_stops_creation_and_remote_failure_is_resumable() { + let dir = fixture(); + let path = dir.path().to_str().unwrap(); + let api = FixtureApi::new(vec![ + ("GET", "user", Ok(json!({"id":2,"login":"other"}))), + ("GET", "user/orgs?per_page=100", Ok(json!([]))), + ]); + assert!(advance_using(path, "fixture-plan", "create", plan(), &api) + .unwrap_err() + .contains("account changed")); + assert!(api.writes.borrow().is_empty()); + let repo = Repo::discover(path).unwrap(); + repo.add_remote("publish", "https://example.test/other.git", None) + .unwrap(); + let mut s = plan(); + s.stage = "created".into(); + let api = FixtureApi::new(vec![]); + let s = advance_using(path, "fixture-plan", "attach", s, &api).unwrap(); + assert_eq!(s.stage, "created"); + assert!(s.error.as_ref().unwrap().contains("another destination")); + repo.remove_remote("publish").unwrap(); + let s = advance_using(path, "fixture-plan", "attach", s, &api).unwrap(); + assert_eq!(s.stage, "remote_ready"); + assert_eq!( + repo.configured_remote_url("publish").unwrap().unwrap(), + s.clone_url + ); + assert!(repo.refs().unwrap().remote_branches.is_empty()); + api.done(); + } + #[test] + fn explicit_push_sends_only_reviewed_object_and_preserves_existing_upstream() { + let dir = fixture(); + let path = dir.path().to_str().unwrap(); + let bare = tempfile::tempdir().unwrap(); + run_command( + bare.path().to_str().unwrap(), + "git", + &["init", "--bare", "-q"], + &[], + ) + .unwrap(); + let mut s = plan(); + s.clone_url = bare.path().to_str().unwrap().into(); + s.head = String::from_utf8(run_command(path, "git", &["rev-parse", "HEAD"], &[]).unwrap()) + .unwrap() + .trim() + .into(); + attach(path, &s).unwrap(); + run_command( + path, + "git", + &["config", "branch.main.remote", "upstream"], + &[], + ) + .unwrap(); + run_command( + path, + "git", + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "new local commit", + ], + &[], + ) + .unwrap(); + push(path, &s).unwrap(); + let pushed = run_command( + bare.path().to_str().unwrap(), + "git", + &["rev-parse", "refs/heads/main"], + &[], + ) + .unwrap(); + assert_eq!(String::from_utf8_lossy(&pushed).trim(), s.head); + assert_eq!( + String::from_utf8_lossy( + &run_command(path, "git", &["config", "branch.main.remote"], &[]).unwrap() + ) + .trim(), + "upstream" + ); + Repo::discover(path) + .unwrap() + .set_remote_urls( + "publish", + &s.clone_url, + Some("https://elsewhere.test/app.git"), + ) + .unwrap(); + assert!(push(path, &s).unwrap_err().contains("destination changed")); + } +} diff --git a/crates/strand-tauri/src/pull_requests/transport.rs b/crates/strand-tauri/src/pull_requests/transport.rs new file mode 100644 index 00000000..3322e5b2 --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/transport.rs @@ -0,0 +1,460 @@ +//! Host-scoped hosted API transport. Credentials never cross provider origins. +use super::{run_command_input, Result}; +use serde_json::Value; +use std::{collections::HashSet, io::Read, time::Duration}; +use zeroize::Zeroizing; + +pub(crate) fn segment(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()) + .collect::() + .replace('+', "%20") +} + +pub(crate) fn validate_host(host: &str) -> Result<()> { + let url = + url::Url::parse(&format!("https://{host}/")).map_err(|_| "Invalid hosting hostname")?; + if host.is_empty() + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || host.contains(['/', '\\', '@', '?', '#', '\r', '\n']) + { + return Err( + "Enter a hostname with optional port, without a URL path or credentials".into(), + ); + } + Ok(()) +} + +pub(crate) trait Api { + fn request(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result>; + fn json(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result { + let bytes = self.request(method, endpoint, body)?; + if bytes.is_empty() { + return Ok(Value::Null); + } + serde_json::from_slice(&bytes).map_err(|_| "Provider returned invalid JSON".into()) + } +} + +pub(crate) struct Client<'a> { + pub cwd: &'a str, + pub provider: &'a str, + pub host: &'a str, +} + +impl Api for Client<'_> { + fn request(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result> { + validate_host(self.host)?; + if endpoint.starts_with('/') + || endpoint.contains("://") + || endpoint.contains(['\r', '\n', '\0']) + { + return Err("Invalid provider API endpoint".into()); + } + let input = body + .map(serde_json::to_vec) + .transpose() + .map_err(|e| e.to_string())?; + if self.provider == "bitbucket" { + if self.host != "bitbucket.org" { + return Err("Only Bitbucket Cloud is supported".into()); + } + // API tokens use the API origin and Atlassian account email. Never + // reuse a GitHub/GitLab token or send credentials to a returned link. + let scratch = tempfile::tempdir().map_err(|e| e.to_string())?; + let bytes = Zeroizing::new(run_command_input( + &scratch.path().to_string_lossy(), "git", &["credential", "fill"], + &[("GIT_TERMINAL_PROMPT", "0"), ("GCM_INTERACTIVE", "never")], + Some(b"protocol=https\nhost=api.bitbucket.org\n\n"), + ).map_err(|_| "Configure a Bitbucket API credential for https://api.bitbucket.org in your Git credential helper (Atlassian email and scoped API token)".to_string())?); + let credential = + std::str::from_utf8(&bytes).map_err(|_| "Invalid Bitbucket credential")?; + let username = credential + .lines() + .find_map(|line| line.strip_prefix("username=")) + .ok_or("Bitbucket API credential has no username")?; + let password = credential + .lines() + .find_map(|line| line.strip_prefix("password=")) + .ok_or("Bitbucket API credential has no token")?; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| e.to_string())?; + let mut request = client + .request( + method.parse().map_err(|_| "Invalid HTTP method")?, + format!("https://api.bitbucket.org/2.0/{endpoint}"), + ) + .basic_auth(username, Some(password)) + .header("Accept", "application/json"); + if let Some(input) = input { + request = request + .header("Content-Type", "application/json") + .body(input); + } + let mut response = request + .send() + .map_err(|_| "Bitbucket API request failed; check your connection")?; + // Cloud's PR diff endpoint redirects to the same repository's + // immutable diff route. Follow only that documented read redirect. + if response.status().is_redirection() && method == "GET" && endpoint.ends_with("/diff") + { + let location = response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or("Bitbucket diff redirect has no location")?; + let location = bitbucket_diff_redirect(location, endpoint)?; + response = client + .get(location) + .basic_auth(username, Some(password)) + .send() + .map_err(|_| "Bitbucket diff request failed")?; + } + if !response.status().is_success() { + return Err(format!("Bitbucket API returned HTTP {}. Check account permissions and token scopes; refresh before retrying a write.", response.status())); + } + let mut bytes = Vec::new(); + response + .take(16 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 16 * 1024 * 1024 { + return Err("Provider response exceeds 16 MB".into()); + } + return Ok(bytes); + } + let cli = if self.provider == "github" { + "gh" + } else { + "glab" + }; + let mut args = vec!["api", "--hostname", self.host, "--method", method, endpoint]; + if input.is_some() { + args.extend(["--input", "-"]); + } + let bytes = run_command_input( + self.cwd, + cli, + &args, + &[ + ("GH_HOST", self.host), + ("GH_PROMPT_DISABLED", "1"), + ("GITLAB_HOST", self.host), + ("GLAB_CHECK_UPDATE", "false"), + ], + input.as_deref(), + )?; + if bytes.len() > 16 * 1024 * 1024 { + return Err("Provider response exceeds 16 MB".into()); + } + Ok(bytes) + } +} + +/// Fully traverse new adapters' collections. Fail explicitly on malformed, +/// repeated, or oversized pagination instead of reporting a partial set. +pub(crate) fn pages(api: &impl Api, endpoint: &str, bitbucket: bool) -> Result> { + let join = if endpoint.contains('?') { '&' } else { '?' }; + let mut next = format!( + "{endpoint}{join}{}=100", + if bitbucket { "pagelen" } else { "per_page" } + ); + let mut visited = HashSet::new(); + let mut ids = HashSet::new(); + let mut items = Vec::new(); + for page in 1..=500 { + if !visited.insert(next.clone()) { + return Err("Provider repeated a pagination cursor; collection is incomplete".into()); + } + let value = api.json("GET", &next, None)?; + let rows = if bitbucket { + value.get("values") + } else { + Some(&value) + } + .and_then(Value::as_array) + .ok_or("Provider returned an invalid collection")?; + for row in rows { + let id = row + .get("id") + .or_else(|| row.get("uuid")) + .or_else(|| row.get("hash")) + .map(Value::to_string); + if id.is_none() || ids.insert(id.unwrap()) { + items.push(row.clone()); + } + } + if bitbucket { + let Some(link) = value.get("next").and_then(Value::as_str) else { + return Ok(items); + }; + next = bitbucket_next(link, endpoint)?; + } else { + if rows.len() < 100 { + return Ok(items); + } + next = format!("{endpoint}{join}per_page=100&page={}", page + 1); + } + } + Err("Provider collection exceeds 500 pages; narrow the query on the provider website".into()) +} + +fn bitbucket_next(link: &str, endpoint: &str) -> Result { + let url = url::Url::parse(link).map_err(|_| "Invalid Bitbucket pagination link")?; + let original_path = endpoint.split('?').next().unwrap_or_default(); + if url.scheme() != "https" + || url.host_str() != Some("api.bitbucket.org") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url.path() != format!("/2.0/{original_path}") + { + return Err("Rejected Bitbucket pagination outside the requested API collection".into()); + } + Ok(format!( + "{}{}", + url.path().trim_start_matches("/2.0/"), + url.query().map(|q| format!("?{q}")).unwrap_or_default() + )) +} + +fn bitbucket_diff_redirect(link: &str, endpoint: &str) -> Result { + let url = url::Url::parse(link).map_err(|_| "Invalid Bitbucket diff redirect")?; + let repo = endpoint + .split("/pullrequests/") + .next() + .ok_or("Invalid Bitbucket diff route")?; + if url.scheme() != "https" + || url.host_str() != Some("api.bitbucket.org") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || !url.path().starts_with(&format!("/2.0/{repo}/diff/")) + { + return Err("Rejected Bitbucket diff redirect outside this repository".into()); + } + Ok(url) +} + +pub(super) struct GitHubContext<'a> { + pub path: &'a str, + pub host: &'a str, +} + +impl GitHubContext<'_> { + pub fn scope_avatar(&self, avatar: &mut Option) { + if self.host != "github.com" { + if let Some(path) = avatar + .as_ref() + .and_then(|url| url.strip_prefix("https://github.com/")) + { + *avatar = Some(format!("https://{}/{path}", self.host)); + } + } + } + pub fn slug(&self, owner: &str, repo: &str) -> String { + if self.host == "github.com" { + format!("{owner}/{repo}") + } else { + format!("{}/{owner}/{repo}", self.host) + } + } +} + +pub(super) fn github_command( + cwd: &GitHubContext<'_>, + program: &str, + args: &[&str], + envs: &[(&str, &str)], +) -> Result> { + github_command_input(cwd, program, args, envs, None) +} + +pub(super) fn github_command_input( + cwd: &GitHubContext<'_>, + program: &str, + args: &[&str], + envs: &[(&str, &str)], + input: Option<&[u8]>, +) -> Result> { + let mut scoped = envs.to_vec(); + scoped.push(("GH_HOST", cwd.host)); + // Explicit --hostname also scopes GraphQL IDs, viewer identity and REST. + let mut args = args.to_vec(); + if program == "gh" && args.first() == Some(&"api") { + args.extend(["--hostname", cwd.host]); + } + run_command_input(cwd.path, program, &args, &scoped, input) +} + +#[cfg(test)] +pub(crate) mod fixtures { + use super::*; + use std::{cell::RefCell, collections::VecDeque}; + pub struct FixtureApi { + steps: RefCell)>>, + pub writes: RefCell>, + } + impl FixtureApi { + pub fn new(steps: Vec<(&str, &str, Result)>) -> Self { + Self { + steps: RefCell::new( + steps + .into_iter() + .map(|(m, e, v)| (m.into(), e.into(), v)) + .collect(), + ), + writes: RefCell::new(vec![]), + } + } + pub fn done(&self) { + assert!( + self.steps.borrow().is_empty(), + "Unconsumed fixture requests: {:?}", + self.steps.borrow() + ); + } + } + impl Api for FixtureApi { + fn request(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result> { + let (m, e, response) = self + .steps + .borrow_mut() + .pop_front() + .expect("Unexpected API request"); + assert_eq!((method, endpoint), (m.as_str(), e.as_str())); + if method != "GET" { + self.writes + .borrow_mut() + .push((endpoint.into(), body.cloned().unwrap_or(Value::Null))); + } + response.map(|v| serde_json::to_vec(&v).unwrap()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fixtures::FixtureApi; + use serde_json::json; + #[test] + fn github_identity_preserves_public_follows_and_separates_custom_hosts() { + let public = GitHubContext { + path: ".", + host: "github.com", + }; + let enterprise = GitHubContext { + path: ".", + host: "git.example:8443", + }; + assert_eq!(public.slug("team", "app"), "team/app"); + assert_eq!(enterprise.slug("team", "app"), "git.example:8443/team/app"); + let mut avatar = Some("https://github.com/reviewer.png?size=80".into()); + enterprise.scope_avatar(&mut avatar); + assert_eq!( + avatar.as_deref(), + Some("https://git.example:8443/reviewer.png?size=80") + ); + } + #[test] + fn gitlab_paginates_and_deduplicates_101_entries() { + let first = (1..=100).map(|id| json!({"id":id})).collect::>(); + let api = FixtureApi::new(vec![ + ( + "GET", + "projects/a%2Fb/merge_requests?per_page=100", + Ok(json!(first)), + ), + ( + "GET", + "projects/a%2Fb/merge_requests?per_page=100&page=2", + Ok(json!([{"id":100},{"id":101}])), + ), + ]); + assert_eq!( + pages(&api, "projects/a%2Fb/merge_requests", false) + .unwrap() + .len(), + 101 + ); + api.done(); + } + #[test] + fn bitbucket_follows_opaque_next_even_on_short_pages() { + let api = FixtureApi::new(vec![ + ( + "GET", + "repositories/a/b/pullrequests?pagelen=100", + Ok( + json!({"values":[{"id":1}],"next":"https://api.bitbucket.org/2.0/repositories/a/b/pullrequests?cursor=opaque"}), + ), + ), + ( + "GET", + "repositories/a/b/pullrequests?cursor=opaque", + Ok(json!({"values":[{"id":1},{"id":2}]})), + ), + ]); + assert_eq!( + pages(&api, "repositories/a/b/pullrequests", true) + .unwrap() + .len(), + 2 + ); + api.done(); + } + #[test] + fn pagination_fails_on_permission_errors_and_foreign_cursors() { + let api = FixtureApi::new(vec![( + "GET", + "user/workspaces?pagelen=100", + Err("HTTP 403".into()), + )]); + assert!(pages(&api, "user/workspaces", true) + .unwrap_err() + .contains("403")); + for link in [ + "https://evil.test/2.0/user/workspaces?page=2", + "https://api.bitbucket.org/2.0/user?page=2", + "http://api.bitbucket.org/2.0/user/workspaces", + "https://token@api.bitbucket.org/2.0/user/workspaces", + ] { + assert!(bitbucket_next(link, "user/workspaces").is_err()); + } + assert!(bitbucket_diff_redirect( + "https://api.bitbucket.org/2.0/repositories/a/b/diff/head..base", + "repositories/a/b/pullrequests/1/diff" + ) + .is_ok()); + assert!(bitbucket_diff_redirect( + "https://api.bitbucket.org/2.0/repositories/other/b/diff/head", + "repositories/a/b/pullrequests/1/diff" + ) + .is_err()); + } + #[test] + fn hostname_rejects_credentials_paths_and_query_strings() { + for host in [ + "https://git.example", + "token@git.example", + "git.example/api", + "git.example?x=1", + "", + "git.example\\x", + ] { + assert!(validate_host(host).is_err(), "{host}"); + } + assert!(validate_host("git.example:8443").is_ok()); + assert_eq!(segment("nested/team x/repo"), "nested%2Fteam%20x%2Frepo"); + } +} diff --git a/crates/strand-tauri/src/user_actions.rs b/crates/strand-tauri/src/user_actions.rs new file mode 100644 index 00000000..e7473615 --- /dev/null +++ b/crates/strand-tauri/src/user_actions.rs @@ -0,0 +1,383 @@ +//! Explicit user commands, separate from Workbench/plugin registries. +use crate::ai::bin::{self, AiCancelHandle}; +use serde::Serialize; +use std::{ + path::Path, + process::Stdio, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; +use strand_core::{ + user_actions::{ActionContext, ActionPreview, UserAction}, + Repo, +}; + +const OUTPUT_LIMIT: usize = 128 * 1024; // Per pipe; never buffer an unbounded transcript. +const TIMEOUT: Duration = Duration::from_secs(600); + +#[cfg(unix)] +static LIVE_GROUPS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// App exit must stop action descendants as well as embedded terminals. +pub fn shutdown() { + #[cfg(unix)] + if let Ok(groups) = LIVE_GROUPS.lock() { + for pid in groups.iter() { + // SAFETY: these are only process groups created by action commands. + unsafe { + libc::kill(-(*pid as i32), libc::SIGKILL); + } + } + } + // On Windows, KILL_ON_JOB_CLOSE handles app exit (including a crash). +} + +pub fn preview(action: &UserAction, context: &ActionContext) -> Result { + let mut preview = Repo::discover(&context.path) + .map_err(|e| e.to_string())? + .preview_user_action(action, context) + .map_err(|e| e.to_string())?; + let path = Path::new(&action.executable); + if !path.is_absolute() && (action.executable.contains(['/', '\\']) || action.executable == ".") + { + return Err("Use an absolute executable path or a command installed on PATH".into()); + } + #[cfg(windows)] + let name = if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) + { + &action.executable[..action.executable.len() - 4] + } else { + &action.executable + }; + #[cfg(not(windows))] + let name = &action.executable; + let executable = bin::resolve_cli( + name, + path.is_absolute().then_some(action.executable.as_str()), + ) + .ok_or_else(|| { + "Executable not found. Use an installed command or absolute executable path.".to_string() + })?; + // Batch shims introduce cmd.exe reparsing of repository-controlled values. + // Users can invoke a native interpreter with a script path as an argument. + #[cfg(windows)] + if !executable + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) + { + return Err("Actions require a native .exe on Windows; pass scripts as arguments to their interpreter.".into()); + } + preview.executable = executable.to_string_lossy().into_owned(); + Ok(preview) +} + +#[derive(Debug, Serialize)] +pub struct ActionOutcome { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, + pub status: String, + pub truncated: bool, + pub duration_ms: u64, +} + +pub fn run(preview: &ActionPreview, cancel: &AiCancelHandle) -> Result { + capture(preview, cancel, TIMEOUT) +} + +fn capture( + preview: &ActionPreview, + cancel: &AiCancelHandle, + timeout: Duration, +) -> Result { + if cancel.is_cancelled() { + return Err("cancelled".into()); + } + let mut command = bin::base_command(Path::new(&preview.executable), true); + command + .args(&preview.args) + .current_dir(&preview.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + capture_command(command, cancel, timeout) +} + +fn capture_command( + mut command: std::process::Command, + cancel: &AiCancelHandle, + timeout: Duration, +) -> Result { + let start = Instant::now(); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command + .spawn() + .map_err(|e| format!("Could not start action: {e}"))?; + #[cfg(windows)] + let job = match bin::WindowsJob::assign(&child).and_then(|job| { + job.kill_on_close()?; + Ok(job) + }) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + #[cfg(unix)] + if let Ok(mut groups) = LIVE_GROUPS.lock() { + groups.push(child.id()); + } + let exceeded = Arc::new(AtomicBool::new(false)); + let stdout = child + .stdout + .take() + .map(|pipe| bin::drain_pipe_untrimmed(pipe, OUTPUT_LIMIT, exceeded.clone())); + let stderr = child + .stderr + .take() + .map(|pipe| bin::drain_pipe_untrimmed(pipe, OUTPUT_LIMIT, exceeded.clone())); + let (status, exit_code) = loop { + if cancel.is_cancelled() { + break ("cancelled", None); + } + if exceeded.load(Ordering::Acquire) { + break ("output-limit", None); + } + if start.elapsed() >= timeout { + break ("timed-out", None); + } + match child.try_wait() { + Ok(Some(exit)) => { + break ( + if exit.success() { + "completed" + } else { + "failed" + }, + exit.code(), + ) + } + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(_) => break ("failed", None), + } + }; + // Kill descendants even after a natural parent exit: they may still hold + // stdout/stderr open. Joining reader threads first can hang cancellation. + #[cfg(unix)] + bin::kill_process_tree(&mut child); + #[cfg(windows)] + bin::kill_process_tree(&mut child, &job); + let _ = child.wait(); + #[cfg(unix)] + if let Ok(mut groups) = LIVE_GROUPS.lock() { + groups.retain(|pid| *pid != child.id()); + } + let stdout = bin::join_pipe(stdout); + let stderr = bin::join_pipe(stderr); + let truncated = stdout.exceeded || stderr.exceeded; + Ok(ActionOutcome { + stdout: stdout.text, + stderr: stderr.text, + exit_code, + status: if truncated && status != "cancelled" { + "output-limit" + } else { + status + } + .into(), + truncated, + duration_ms: start.elapsed().as_millis() as u64, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A child of the test executable provides real argv/pipe/process behavior + // on every platform, without shell quoting or installed test runtimes. + #[test] + fn action_child() { + let Ok(mode) = std::env::var("STRAND_ACTION_TEST") else { + return; + }; + match mode.as_str() { + "echo" => { + println!("{:?}", std::env::args().collect::>()); + println!("cwd={}", std::env::current_dir().unwrap().display()); + eprintln!("stderr retained"); + } + "fail" => { + eprintln!("intentional failure"); + std::process::exit(7); + } + "flood" => loop { + println!("{}", "x".repeat(8192)); + }, + "wait" => { + println!("started"); + std::thread::sleep(Duration::from_secs(60)); + } + "descendant" => { + let mut child = child_command("mark"); + child.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + child.spawn().unwrap(); + println!("spawned child"); + std::thread::sleep(Duration::from_secs(60)); + } + "parent-exit" => { + child_command("wait") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + } + "mark" => { + std::thread::sleep(Duration::from_secs(2)); + std::fs::write(std::env::var("STRAND_ACTION_MARKER").unwrap(), "escaped").unwrap(); + } + _ => panic!("bad mode"), + } + } + + fn child_command(mode: &str) -> std::process::Command { + let mut command = bin::base_command(&std::env::current_exe().unwrap(), true); + command + .args([ + "--exact", + "user_actions::tests::action_child", + "--nocapture", + ]) + .env("STRAND_ACTION_TEST", mode) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + } + + #[test] + fn captures_exact_arguments_working_directory_and_both_pipes() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("space & %PATH% {repo}"); + std::fs::create_dir(&dir).unwrap(); + let mut command = child_command("echo"); + let hostile = "a b;&%PATH%{repo}'$(echo)"; + command.args(["--skip", hostile]).current_dir(&dir); + let output = + capture_command(command, &AiCancelHandle::new(), Duration::from_secs(15)).unwrap(); + assert_eq!(output.status, "completed"); + assert!(output.stdout.contains(hostile), "{}", output.stdout); + assert!(output.stdout.contains("space & %PATH% {repo}")); + assert!(output.stderr.contains("stderr retained")); + let output = capture_command( + child_command("fail"), + &AiCancelHandle::new(), + Duration::from_secs(15), + ) + .unwrap(); + assert_eq!(output.exit_code, Some(7)); + assert_eq!(output.status, "failed"); + assert!(output.stderr.contains("intentional failure")); + } + + #[test] + fn bounds_output_and_timeout_and_does_not_wait_for_exited_parents_descendants() { + let output = capture_command( + child_command("flood"), + &AiCancelHandle::new(), + Duration::from_secs(15), + ) + .unwrap(); + assert_eq!(output.status, "output-limit"); + assert!(output.truncated); + assert!(output.stdout.len() <= OUTPUT_LIMIT); + let output = capture_command( + child_command("wait"), + &AiCancelHandle::new(), + Duration::from_millis(300), + ) + .unwrap(); + assert_eq!(output.status, "timed-out"); + let start = Instant::now(); + let output = capture_command( + child_command("parent-exit"), + &AiCancelHandle::new(), + Duration::from_secs(15), + ) + .unwrap(); + assert_eq!(output.status, "completed"); + assert!(start.elapsed() < Duration::from_secs(10)); + } + + #[test] + fn cancellation_stops_descendants_and_pre_cancel_never_spawns() { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("marker"); + let mut command = child_command("descendant"); + command.env("STRAND_ACTION_MARKER", &marker); + let cancel = AiCancelHandle::new(); + let trigger = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(700)); + trigger.cancel(); + }); + let output = capture_command(command, &cancel, Duration::from_secs(10)).unwrap(); + assert_eq!(output.status, "cancelled"); + assert!(output.stdout.contains("spawned child")); + std::thread::sleep(Duration::from_secs(2)); + assert!(!marker.exists()); + assert_eq!( + capture( + &ActionPreview { + executable: "missing".into(), + args: vec![], + cwd: ".".into() + }, + &cancel, + TIMEOUT + ) + .unwrap_err(), + "cancelled" + ); + } + + #[test] + fn resolves_installed_executable_before_repository_cwd_and_rejects_relative_program() { + // `git init` is delegated to core's normal creation fixture helpers elsewhere; + // this test uses this worktree's actual repository read-only. + let context = ActionContext { + path: env!("CARGO_MANIFEST_DIR").into(), + target: strand_core::user_actions::ActionTarget::Repository, + }; + let mut action = UserAction { + id: "test".into(), + name: "Test".into(), + scope: "repository".into(), + executable: std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(), + args: vec![], + cwd: "repository".into(), + }; + let resolved = preview(&action, &context).unwrap(); + assert!(Path::new(&resolved.executable).is_absolute()); + action.executable = if cfg!(windows) { "git.EXE" } else { "git" }.into(); + let resolved = preview(&action, &context).unwrap(); + assert!(Path::new(&resolved.executable).is_absolute()); + assert!(resolved.args.iter().any(|arg| arg == "core.fsmonitor=")); + action.executable = "./repo-program".into(); + assert!(preview(&action, &context).unwrap_err().contains("absolute")); + } +} diff --git a/docs/git-assets-validation-2026-09-06.md b/docs/git-assets-validation-2026-09-06.md new file mode 100644 index 00000000..3c678f5d --- /dev/null +++ b/docs/git-assets-validation-2026-09-06.md @@ -0,0 +1,73 @@ +# Git LFS and submodule validation — 2026-09-06 + +F04 and F05 are implemented through core operations, typed IPC, sidebar entries +and keyboard-operable dialogs. Verification used Windows, Git 2.45.1 and Git +LFS 3.5.1, with disposable repositories and local Git/HTTP endpoints. + +## Regressions reproduced and fixed + +- **LFS index bytes:** git2 `index.add_path` stored the asset instead of the + canonical pointer produced by `git hash-object --path`. Single-file and bulk + staging now run Git's required LFS clean filter. Fixtures compare the actual + index/commit bytes, then check checkout, discard, hard reset, push and pull. + A missing filter executable fails without changing the index to raw assets. +- **Submodule ignored files:** non-forced `git submodule deinit` removed an + ignored local file. Removal/deinit now check ignored files and recorded + commits in every initialized descendant. The fixture covers both direct + and nested ignored files, dirty/untracked files, and unrecorded commits. +- **Native dialog integration:** corrected shared-select styling and routed + module opening through the workspace store, so workspace reconciliation + retains the new active repository. + +## Automated checks + +Run from the repository root: + +```text +cargo check -p strand-core -p strand-tauri -j 2 +cargo clippy -p strand-core -p strand-tauri -j 2 -- -D warnings +cargo test -p strand-core --lib -j 2 -- --test-threads=2 +pnpm --filter ./ui exec tsc --noEmit +pnpm --filter ./ui test +``` + +All checks passed: 169 core tests and 425 frontend tests across 75 files. + +Core coverage lives in `lfs.rs`, `submodule.rs` and +`network::bounded_process_tests`. Fixtures also verify local setup/tracking +without history changes, server lock/list/unlock, safe argument handling, +submodule transport, `.gitmodules` and gitlink staging, URL/index preservation, +deinit/reinit, lazy nested metadata, bounded output and cancellation of helpers +that keep progress pipes open. Linux CI installs Git LFS for these real fixtures. + +## Native verification + +Followed `.agents/skills/verify/SKILL.md` with an isolated application identifier, +WebView2 profile, Vite port and disposable repositories. The checked-in Tauri +configuration was never edited. The verification instance was stopped by its +recorded PID and the normal dev binary rebuilt without the CDP override. + +- LFS: sidebar setup and tracking, Stage all/commit with canonical pointer + bytes, object listing from the command palette, initial focus, styled fields, + no network on opening, cancellation and subsequent successful environment read. +- Submodules: real add/clone, status, lazy nested navigation, opening the module + as a repository, URL changes/sync, confirmation by keyboard, dirty-file refusal, + deinit/reinit/removal and retained module history. A stalled add cancelled + without changing the index. The rebuilt app also refused deinit when an + ignored local file was present, preserving its exact contents and the index. +- Cancellation of stalled local HTTP requests completed in approximately + 0.44 seconds for LFS and 0.48 seconds for submodule add. These are fixture + observations, not general performance certification. + +## Boundaries + +Lock listing is capped at 100 with an exact-path filter; object transcripts +retain a bounded tail. Submodule metadata is paged at 100 per level; dirty +inspection is explicit and destructive preflight may walk descendants. LFS +history migration and partial-pointer patches are intentionally absent. + +Hosted authentication/locking services and macOS/Linux runtime behavior were +not exercised here. The LFS network fixture clones without checkout and then +uses Strand checkout because this Git/LFS combination rejects a hook installed +during normal clone checkout. Advanced clone compatibility remains tracked +under F09; no system safety override was added. diff --git a/docs/hooks-identity-signing-validation-2026-09-06.md b/docs/hooks-identity-signing-validation-2026-09-06.md new file mode 100644 index 00000000..d19a052f --- /dev/null +++ b/docs/hooks-identity-signing-validation-2026-09-06.md @@ -0,0 +1,113 @@ +# F01–F03 validation — 2026-09-06 + +Base: `263ebe6` (PR #114). Windows, system Git, isolated worktree and fixtures. + +## F01 — commit hooks + +Commit and amend always use system Git. This explicitly supersedes the old +unsigned git2 path documented in learnings; index operations remain on git2. +Git owns hook lookup, `core.hooksPath`, rejection, message rewrites, signing, +merge parents and amend attribution. The command runs on the blocking pool. +Stdout and stderr are drained concurrently, keeping each stream’s first/last +8 KiB with an explicit truncation marker. Drafts are checkout-keyed and survive +failed operations and view/repository changes during the session. + +Evidence: +- Core fixtures: custom hooksPath, rejecting pre-commit and commit-msg, + prepare-commit-msg/commit-msg rewriting, post-commit output, amend post-rewrite + old/new OIDs, preserved index/HEAD on rejection, bounded verbose output, + attribution on amend and missing SSH signing key failure. +- Store/shortcut tests: 12 passed, including rejected-hook index refresh, + completed commit with failed refresh, and repository-switch response handling. +- Frontend TypeScript: passed. +- Isolated native WebView2: Ctrl+Enter rejection kept subject/body; switching + to Commits and back retained the draft; retry ran message rewriting and + exposed successful hook output. Verified the resulting commit message with + Git. Screenshots retained under `target/verify-f010203/f01-*.png` (local only). +- Manual 25-iteration debug measurement after concurrent builds settled: + system Git median **81.01 ms**, p95 **87.72 ms**; former git2 algorithm median + **7.59 ms**, p95 **8.47 ms**. Under earlier concurrent compilation these were + 529.01/797.32 ms and 24.50/97.98 ms respectively (median/p95). + This is an explicit correctness cost on commit, + not a status/staging hot-path change or an idle performance certification. + Reproduce with `cargo test -p strand-core measure_no_hook_commit_path -- + --ignored --nocapture`. Both measurements exclude staging. + +Git contracts: [hooks](https://git-scm.com/docs/githooks), +[commit](https://git-scm.com/docs/git-commit). + +## F02 — repository identity + +- Core: three gitconfig tests passed, including conditional include contents + left byte-for-byte intact; separate repository values unaffected; common + local config shared across linked worktrees; explicit worktree identity + retained after removal of common local name. +- Native WebView2: Settings → Git displayed effective author/committer and + field provenance; Save name changed the effective identity; Remove name + override restored inheritance; opening a second repository displayed that + repository’s own identity. Local screenshots: `f02-local-identity.png` and + `f02-separate-repository.png` under `target/verify-f010203/`. +- The Git settings entry is available in the command palette; all fields and + actions use native inputs/buttons within the existing Settings tab model. +- Frontend TypeScript and `cargo check -p strand-core -p strand-tauri` passed. + +Git contracts: [config scope and includes](https://git-scm.com/docs/git-config). + +## F03 — signing controls and signed tags + +Commit/amend and tag creation accept inherit/sign/unsigned without writing a +configuration override. Inheritance is resolved by system Git; explicit +unsigned annotated tags suppress both `tag.gpgSign` and `tag.forceSignAnnotated`. +Settings show effective values/provenance and save/remove only direct local or +explicitly enabled worktree keys. Signing uses Git's existing agents, signing +program and key references. Verification resolves an immutable tag object and +returns unsigned/verified/failed plus bounded Git output; validity does not +silently imply signer trust. No graph-wide verification was added. + +Evidence: +- Full core suite passed at 167 tests before the final boolean-parser fixture. + The final run had **167 passed, one failed, three ignored**: unchanged + `watch::tests::debounce_collapses_a_burst_into_one_callback` observed two + callbacks instead of one, and failed again in isolation. The watcher source, + core dependency manifest and lockfile are unchanged from the task base. + This is recorded as a follow-up; the final full core suite is not green. + All hook/identity/signing/tag tests passed, including the added boolean case. + Full frontend suite: 75 files, 430 tests passed, + including refresh failure after successful signing and switching + repositories while a signer runs. +- Explicit real GPG and SSH fixtures both passed. Each covers new signed + commit, inherited signed amend, per-operation unsigned amend without config + mutation, hooks rewriting/rejecting signed commits, explicit/inherited/ + force-annotated signed tags, unsigned annotated/lightweight overrides, + tampered-signature failure, and missing-key commit/tag failure with unchanged + HEAD and no failed tag ref. SSH also covers a missing allowed signers file. +- Each real-signature fixture exercises a linked worktree, inherited signing, + then a worktree-only unsigned default while common local defaults and the + main checkout's HEAD remain intact. A separate scope test rejects worktree + writes when the extension is disabled and confirms removal restores inherited + values without affecting another repository. +- Config parsing distinguishes Git's valueless boolean (`true`) from an + explicitly empty boolean (`false`), with a fixture for both and the `yes` alias. +- Native WebView2: Settings → Git saved SSH format, key reference, allowed + signers path and commit/tag defaults. Ctrl+Enter created a verified signed + commit; amend also verified. The palette opened signed-tag creation and + verification. A missing-key amend preserved HEAD, draft and signing choice. + The final native pass used Enter in the palette, confirmed a failed signed + tag kept name/message/choice and created no ref, retried it unsigned against + both signing defaults, and verified an inherited force-annotated signature. + Worktree-scoped save/remove restored inheritance while main config stayed + unchanged. + Screenshots are local under `target/verify-f010203/f03-*.png`. +- `cargo check -p strand-core -p strand-tauri` and frontend TypeScript passed. + +The fixtures caught two Git tag details: verbatim messages need a final LF +before the appended signature, and explicit `--annotate` suppresses +`tag.forceSignAnnotated`. Inherited tag creation uses `--file` without that +override. See Git's [tag implementation](https://github.com/git/git/blob/v2.45.1/builtin/tag.c) +and [tag configuration](https://git-scm.com/docs/git-config#Documentation/git-config.txt-tagforceSignAnnotated). + +Reproduce the real-key fixtures with `cargo test -p strand-core signing::tests +-- --ignored --nocapture`. They use generated, disposable keys and isolated +GPG homes; they do not change the user's global Git configuration or keyring. +Validation host: Windows, Git 2.45.1.windows.1. macOS/Linux runtime behavior, +X.509, hardware-backed keys and interactive pinentry were not exercised. diff --git a/docs/hosted-provider-contracts.md b/docs/hosted-provider-contracts.md new file mode 100644 index 00000000..9ce45c97 --- /dev/null +++ b/docs/hosted-provider-contracts.md @@ -0,0 +1,101 @@ +# Hosted provider contracts + +PR integration (2026-09-06): GitHub connection pages and deferred completion +retain the selected custom host, including commit links and avatars. New +provider adapters expose complete list counts and stable check IDs without +claiming unsupported suggestion coordinates or review-commit boundaries. +Review evolution and deferred completion controls remain GitHub/Azure-only. +The combined hosted Rust suite passes 60 tests. + +Implemented September 6, 2026 in `pull_requests/hosted.rs` and +`pull_requests/transport.rs`. GitHub and Azure retain their existing adapters; +custom GitHub hosts pass through `GitHubContext` with an explicit hostname. + +## API and authentication references + +- GitHub: [CLI API hostname](https://cli.github.com/manual/gh_api), + [host and token environment](https://cli.github.com/manual/gh_help_environment), + [custom API host configuration](https://cli.github.com/manual/gh_config_set). +- GitLab: [CLI API](https://docs.gitlab.com/cli/api/), + [merge requests](https://docs.gitlab.com/api/merge_requests/), + [discussions and diff coordinates](https://docs.gitlab.com/api/discussions/), + [approval SHA](https://docs.gitlab.com/api/merge_request_approvals/). +- Bitbucket Cloud: [pull requests](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/), + [workspace repository permissions](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/), + [workspaces](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/), + [API token permissions](https://support.atlassian.com/bitbucket-cloud/docs/api-token-permissions/). + +GitLab uses the authenticated CLI host and nested project path. GitHub REST, +GraphQL, PR commands and viewer identity use the same explicit host; public +GitHub repository labels retain their earlier owner/repo form. Bitbucket uses +only Cloud's API origin and the system Git credential helper. The helper runs +from a neutral directory, with prompting disabled; credentials are never +included in provider error messages or saved application state. + +## Capabilities and consistency + +GitLab project/group access and Bitbucket's per-workspace repository permissions +inform controls. Permission-query failure is explicit. The provider remains +authoritative for protected branches and token scopes. Pipeline/status results +are not advertised as complete policy evaluations. Shallow list items carry +no optimistic write capabilities before detail loads. + +GitLab merge and approval send the reviewed SHA. Inline discussions include +base/start/head coordinates, rename paths, both sides for context lines, and +versioned range line codes. Bitbucket merge stays disabled because its merge +contract has no atomic expected-head condition. Cloud comments and review +decisions check before/after writing and report races as possibly posted. +Neither adapter claims atomic batch reviews: errors preserve the local draft +and report confirmed writes for reconciliation before retry. + +Bitbucket Server, Cloud merge/reopen/draft transitions/discussion resolution, +GitLab request-changes, and editing/resetting these providers' submitted reviews +remain provider-site actions. Same-repository Bitbucket checkout is supported; +fork checkout requires opening the source fork. These are capability limits, +not silent fallback to another provider. + +Collections traverse GitLab pages and Bitbucket opaque `next` links, including +short intermediate pages, with duplicate and loop protection. Cross-origin or +cross-collection links are rejected. Cloud's documented PR-diff redirect is +allowed only to that repository's API diff route. Limits produce explicit +incomplete-result errors. Selected detail fetches commits/discussions; patches +load on Code; activity polling never fetches patches or commit history. + +## Verification + +The Rust fixtures exercise 101-entry pagination, opaque short pages, permission +denial, custom-host coordinates and identities, rename/context/range comments, +terminal and stale-head rejection, approval SHA, partial review failures and +provider draft markers. Existing GitHub/Azure fixture tests remain in the same +suite. Frontend tests cover provider labels and merge capabilities while +retaining existing GitHub/Azure behavior. + +The isolated Windows Tauri/WebView2 pass exercised GitLab Code rendering, +retained review text after a stale-head rejection, disabled request-changes, +Bitbucket's provider-site merge control, keyboard palette access and real +per-remote settings persistence in a scratch repository. Hosted responses and +writes were injected fixtures; no authenticated live-provider mutation was +performed. The app used a separate identity/profile and temporary embedded +debug configuration; the normal binary was rebuilt after verification. + +## Repository publishing + +`pull_requests/publish.rs` uses GitHub +[repository creation](https://docs.github.com/en/rest/repos/repos), GitLab +[project creation](https://docs.gitlab.com/api/projects/) and +[namespaces](https://docs.gitlab.com/api/namespaces/), and Bitbucket's repository +and workspace APIs linked above. Azure creation is outside this flow. + +The journal in repository-local `strand.publish-state` contains the reviewed +account/destination/visibility, branch and commit, plus recovery stage. It has no +credentials. Creation persists `uncertain` before POST; recovery only performs +GET. Attaching a remote and pushing require separate explicit actions. The +initial push pins the reviewed object, rejects changed or rewritten URLs, +disables implicit tag/submodule pushes and preserves an existing upstream. + +Fixtures cover uncertain creation without duplicate POST, changed accounts, +visibility mismatch and remote conflicts. A real local bare-repository test +proves that only the reviewed object is pushed after a newer local commit and +that an existing upstream survives. The desktop pass exercised destination +review, remote-setup failure, close/resume/retry, an initially unchecked push +checkbox, and completion; it also caught and fixed initial keyboard focus. diff --git a/docs/learnings.md b/docs/learnings.md index e50db001..fabbb8fe 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,11 +1,91 @@ # Learnings +## Bisect ratings belong to the expected revision (2026-09-06) + +Read `BISECT_*` and refs from Git for every dialog refresh/action; these are +worktree-local and may be driven by another client. Map custom terms and +`BISECT_HEAD` for no-checkout sessions, distinguish skipped ambiguity from a +culprit, and reject a rating when HEAD differs from `BISECT_EXPECTED_REV`. +Require a clean tree/index before checkout transitions and reset; test edits +must not be discarded. Review the original ref's current target again before +reset. A bisect marker remaining after a successful merge/rebase does not mean +that sequencer is still paused. Dialogs that remain open after a busy action +must restore focus once controls are enabled again; disabling the focused +button can move focus out of the modal even with a correct Tab trap. + +## Interchange state comes from Git, not a saved UI session (2026-09-06) + +`rebase-apply/applying` identifies `git am`; `rebase-apply` alone can mean a +rebase. Test this before the generic rebase check so Continue/Abort dispatch +to the right porcelain. Mailbox previews parse every message with Git's +mailsplit/mailinfo, preserving authors and checking old/new paths. Imported +paths reject administrative entries and symlink traversal, including missing +descendants; never enable `--unsafe-paths`. Preview stamps include file bytes, +index and HEAD because status-row equality does not prove unchanged content. +Bundle imports publish only a new local branch after verification/unbundle, +using non-forcing ref creation to reject concurrent external branch creation. + +## Sparse indexes and promised blobs require Git-aware paths (2026-09-06) + +libgit2 1.8 cannot read the mandatory sparse-directory index extension and +reports absent skip-worktree entries as deletions even with a full index. +For sparse repositories, use Git for status, working diffs and index mutations; +the compatibility index attached to libgit2 is expanded in memory for readers +only. Never rewrite the user's index merely by opening it. Normal repositories +retain their existing in-process paths. This is a scoped exception to the older +index/commit-engine rule, required by F08's sparse-index semantics. + +Sparse selection must go directly through `sparse-checkout set --cone`; an +init-then-set sequence can remove files between steps. Git may delete ignored +files when excluding a directory. Refuse dirty/untracked work and any ignored +boundary the new cone would exclude; do not stash, clean or discard implicitly. +Use worktree-specific Git configuration and preserve external non-cone patterns +until the user explicitly disables them. +After the guard passes, refresh index stat data before changing the cone: +a clean file restored by an editor can otherwise be retained as "not up to +date". Keep Git's successful warnings visible when it retains files. + +Partial-clone fixtures must actually omit objects (`rev-list --missing=print`). +A successful open is insufficient: historical content, diffs and blame need +Git's lazy object fetching; shallow blame needs Git's boundary semantics. +Keep those reads on demand. Recursive clone cancellation must terminate the +transport/submodule child processes too, because they hold the progress pipes. + Things we've learned while building Strand that aren't otherwise obvious from the PRD / ROADMAP / TASKS files. Append here when you discover something that future work (yours or another agent's) needs to respect. --- +## LFS files need Git's external clean/smudge filters (2026-09-06) + +The real Git LFS fixture proved git2 `index.add_path` stored raw asset bytes +instead of the pointer produced by `git hash-object --path`. The historical +index-on-git2 policy has an LFS exception: detect `filter=lfs` attributes and +stage the complete batch with one literal, NUL-delimited Git pathspec input. +Enforce `filter.lfs.required` so missing tooling cannot silently store raw data. +Whole-file checkout/discard and hard reset use Git for LFS; partial patches are refused. +Ordinary status must not start LFS subprocesses. Management reads are explicit, +transcripts bounded, and cancellation must terminate LFS/submodule descendants +that otherwise keep pipes open. Tracking edits attributes, never history. + +--- + +## Submodule removal must preserve ignored local data (2026-09-06) + +Git's non-forced submodule deinit can remove ignored files, and a parent status +does not report ignored files inside nested modules. Before remove/deinit, +inspect ignored files and recorded commits in each initialized descendant; +refuse the action when local data remains. Keep this work at the explicit +mutation boundary. Nested browsing reads metadata one level/page at a time, +without adding recursive dirty scans to repository refreshes. + +Opening a module from a dialog must go through `useWorkspaces.openRepoInActive`. +Calling `useRepo.openRepo` directly omits workspace membership, and the workspace +reconciler can immediately clear the active repository. + +--- + ## Closeable tabs follow browser closing conventions **Rule.** Every closeable repository or Work tab supports its visible close @@ -359,7 +439,13 @@ the existing pattern better than a forced shared abstraction. behaviour matters more than staying pure-git2: **conflicts** (git leaves markers + the in-progress state on disk), **GPG/SSH signing**, and **hooks** — none of which git2's `merge`/`cherrypick`/`revert` do for free, and git2 has no rebase -driver. Index/commit ops still use git2. After any history op, the store refresh +driver. Index operations still use git2; commit/amend always use system Git +(F01, 2026-09-06), including unsigned commits, so Git owns hook discovery, +rejection, message rewriting, merge parents and effective identity. No hook +existence shortcut: conditional/worktree config and installed hooks can change +between operations. Capture bounded stdout/stderr, preserve checkout drafts on +failure, and do not report post-success refresh errors as commit failures. +After any history op, the store refresh tail is meta + local-changes + log + refs (`refreshAfterHistoryOp`), and a paused op is detected via `Repo::operation_in_progress` reading `.git/` markers (`rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`, `MERGE_HEAD`, @@ -1414,7 +1500,8 @@ PR number alone can then integrate code the user never saw. Provider policies also contain information Strand may not have loaded (especially in Azure DevOps), so client-side green checks are not sufficient authorization. -**How to apply.** GitHub merge commands use `--match-head-commit`; Azure +**How to apply.** GitHub immediate merges use REST `sha`; queue/auto-merge mutations use +`expectedHeadOid`. Azure completion requests include `lastMergeSourceCommit`. Keep required checks, reviews, queues, and branch policies provider-authoritative, preserve their failure text next to the initiating control, and refresh the PR after a @@ -2494,3 +2581,144 @@ Pierre reads `navigator.userAgent` during module evaluation; Node 22's built-in `navigator` hid a failure on CI's Node 20. Stub browser globals and restore them after the test, while retaining real integration assertions. Reproduce this class of failure locally with `--no-experimental-global-navigator`. + +## Hosted connection pages carry completeness and reviewed heads (2026-09-06) + +GitHub connection continuations carry their opaque cursor and the activated +head SHA. Reject missing/repeated cursors and head mismatches; deduplicate by +provider ID when appending, and keep already loaded data on failures. A thread +page fetches only its root comment; replies have independent cursors. Counts +remain explicitly partial until their connections are exhausted. Background +check snapshots traverse check pages without patch or comment-body reads. + +## Deferred completion retains provider semantics (2026-09-06) + +GitHub queue membership, GitHub auto-merge and Azure auto-complete are separate +states. Enabling carries the loaded head; cancellation targets the existing +provider intent and remains possible after source changes. Do not call +`gh pr merge` for immediate completion because it may silently enqueue. Azure +auto-complete follows subsequent source pushes under server policies; its +waiting state is not a queue position. Helper operations advance the protocol +channel and need a corresponding signed helper release. + + +## Hosted evolution uses immutable boundaries and exact local files (2026-09-06) + +A saved reviewed head is a commit ID scoped to the provider PR URL; never replace +it with a branch name or a new merge base after force pushes. Compare the two +explicit trees, retain failed/unavailable boundaries, and label an old result +when the displayed provider head changes. GitHub review commits and Azure +iteration source commits are provider boundaries, not proof that the viewer +reviewed every file. The local **Mark head reviewed** is an explicit action. + +Suggestion application re-reads the provider discussion and head and checks the +exact comment body, current full-line source range, local HEAD, index and file +contents. Revalidate the complete preview before writing through the existing +file compare-and-swap operation. Unknown/old Azure iterations, column ranges, +mixed sides, outdated lines and offset-form fences fail closed. Empty and +blank-line suggestions differ; keep the parsed trailing newline to preserve +that distinction. No staging, commits, pushes or provider writes are implicit. + +Unresolved export is an explicit discussion traversal with cancellation, stable +ID deduplication and source context. Include file-level threads without inventing +line zero annotations; they render above the file diff. Keep this traversal out +of inbox and background monitoring. A read error must not produce a supposedly +complete export or overwrite a review draft. + +**Hosted provider writes preserve host and commit scope (2026-09-06).** +Custom GitHub remotes need an explicit adapter and host-scoped CLI calls; keep +GitHub.com's existing owner/repo identity so saved follows and drafts survive. +GitLab inline coordinates use the diff version's base/start/head and both +paths across a rename. A preflight head read is not an atomic merge guard: +Bitbucket Cloud merge stays unavailable until its API accepts an expected +head. Cloud comment/review races must report that a write may have happened, +and partial batches must retain drafts with reconciliation guidance. Never +reuse another provider's credentials; Cloud API tokens are scoped to +api.bitbucket.org through the system Git credential helper. + +**Repository creation must be resumable before any network write (2026-09-06).** +Persist the concrete destination and reviewed commit locally before creation, +record uncertain state before POST, and recover by GET instead of blindly +reposting. Creation, remote attachment and initial push are separate user +actions. Initial push sends only the reviewed SHA to the reviewed branch, +rejects URL rewrites/alternate push URLs, disables implicit tag/submodule +pushes and preserves an existing upstream. Recovery records contain no tokens. + + +### Advanced refs preserve reviewed identities (2026-09-06) + +Git notes must use a locked namespace tip and publish the prepared notes tree +atomically; a stale note editor must retain its draft when another worktree +changes that namespace. Replacement inspection uses raw object IDs because +libgit2/gix readers do not apply Git's replace refs. Tag retargeting and +re-annotation are separate operations with compare-and-swap of the raw tag ref, +not its peeled commit. Never drop an existing tag signature during an edit. + + +### Git-flow finish is a resumable sequence, not a transaction (2026-09-06) + +Git-flow AVH can finish its production merge and tag before a develop merge +conflicts. Abort must be described as aborting only that current merge; it must +never reset earlier completed stages. Retain workflow branches and use exact +names so a later finish can resume. AVH flags can default from Git config: +explicitly negate every publication flag (including release pushproduction, +pushdevelop and pushtag), not just push. AVH builds its tag command with shell +`eval`; use reviewed generated annotation text from validated names rather +than interpolating arbitrary editor text. Keep tool detection and all of this +metadata off repository-open and graph/diff hot paths. + +## Personal actions preserve argv and captured targets (2026-09-06) + +User actions are personal executable/argv definitions, separate from Workbench +registries and community plugins. Establish argument boundaries before +single-pass placeholder substitution; never interpolate repository-controlled +values into an implicit shell or recursively expand substituted text. Resolve +the executable before adopting the repository cwd. Windows actions require a +native executable, with script paths passed to their interpreter as arguments; +batch shims introduce another command parser. + +Menus capture the invoked repository/ref/file, including inactive repository +tabs. Palette actions require an exact active target, and every run revalidates +the resolved preview and captured ref ID. A sidebar ref click only reveals its +graph row; Enter selects the tip's commit for ref palette actions. Do not infer +a ref from HEAD or a commit shared by several branches/tags. + +Bound stdout and stderr independently. Cancel the whole process tree on close, +timeout, output overflow, or selection changes. Stop descendants even after a +natural parent exit **before** joining pipe readers: inherited stdout/stderr can +otherwise keep the reader joins blocked indefinitely. Replay early cancellation +after native operation registration to cover a closed dialog during IPC startup. + + +## Repository identity must use the commit resolver + +Effective author/committer reads use system Git, including conditional includes, +worktree config and environment overrides. Keep these reads on the explicit +settings surface; do not add subprocesses to snapshot/status paths. Local +identity edits target the direct common repository config, never a file reached +through an include. Show both the saved local values and the effective values: +a later conditional include or a worktree/environment override can still win. +Linked worktrees share local config; `--worktree` writes must never silently +fall back to `--local` when `extensions.worktreeConfig` is disabled. + +## Signing policy must remain Git-compatible (2026-09-06) + +Signing settings store only key references and existing-agent configuration. +Operation-level inherit/sign/unsigned choices never rewrite config, and signing +or hook failures retain the draft. Tag creation runs system Git too: `--file` +already creates an annotated tag, while explicit `--annotate` suppresses +`tag.forceSignAnnotated`. Use that override only for an explicitly unsigned +annotation, alongside `--no-sign`. With verbatim cleanup, ensure a final newline +before Git appends the signature or the result cannot be verified. + +Verify tags lazily against their immutable object ID. Display Git's verification +output and distinguish unsigned, valid and failed results; do not turn signature +validity into an unconditional claim of signer trust. Keep config reads and +signature verification out of status/snapshot and graph-wide refresh paths. +In `git config --null --get-regexp` output, a valueless boolean has no newline +separator and means true; a newline followed by an empty value means false. +Preserve that distinction in settings displays and scoped editing. + +LFS guards must run before sparse-index mutation dispatch. Refresh an attached +memory-only sparse index through `sparse_read_index`, never `Index::read` from +disk; keep one process-tree cancellation helper when composing Git workflows. diff --git a/docs/sparse-clone-verification.md b/docs/sparse-clone-verification.md new file mode 100644 index 00000000..979b703b --- /dev/null +++ b/docs/sparse-clone-verification.md @@ -0,0 +1,44 @@ +# Sparse checkout and clone controls — F08/F09 + +Verified on Windows / WebView2 on 2026-09-06. + +- `cargo check -j 2 -p strand-core -p strand-tauri` passed. +- `cargo test -j 2 -p strand-core --lib --tests -- --test-threads=2`: + 175 tests passed, including 13 integration tests in `sparse_checkout.rs`, + `clone_scope.rs` and `clone_recursive.rs`. +- `corepack pnpm --filter ./ui exec tsc --noEmit` passed. +- `corepack pnpm --filter ./ui exec vitest run --maxWorkers=2 --minWorkers=1`: + 76 files / 427 tests passed. + +The repository's verify skill was run against an isolated native app instance. +The command palette and keyboard controls cloned a selected branch with depth +one, a single-branch refspec and `blob:none`; system Git confirmed each choice. +The history dialog deepened from one to three commits and downloaded all five. +A real stalled HTTP clone displayed progress and cancelled its transport. +History cancellation in an externally cloned shallow repository preserved the +index and restored keyboard focus. + +Live sparse checks enabled a sparse index, changed to a nested directory with +spaces, refused changes after a real deletion without changing index bytes, and +restored every tracked file on disable. Files omitted excluded paths with a +Manage notice; a real included-file deletion produced exactly one deleted row. +Tab/Shift+Tab wrapping, Escape, initial/post-operation focus and the scrollable +880×650 layout were checked. The native debug instance was isolated from the +user's profile and app identifier; the checked-in Tauri config was unchanged. + +Integration fixtures also cover external full/sparse indexes without rewriting +them on read, linked-worktree isolation, non-cone inspection/disable, staged and +unstaged edits, ignored/untracked-file refusal, sparse staging/hunks/commit/ +checkout/discard, literal file paths, restored files with stale index timestamps, +real omitted historical blobs, shallow blame and recursive nested submodules. + +Cone selection is limited to tracked directories in HEAD. External non-cone +patterns must be disabled before editing cone selections. Sparse changes refuse +dirty work rather than implicitly stashing it; they show busy state and Git's +warnings. Streamed progress and cancellation apply to clone/history downloads. +Partial content reads can require network access. Live execution and transport +cancellation validation was Windows only. + +PR integration validation (2026-09-06): the combined LFS/sparse-index fixture +passes pointer staging, filtered discard, and partial-patch rejection without +expanding the on-disk sparse index. Rust checks and TypeScript pass. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ea335854..fab8f792 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,6 +32,9 @@ import { buildContentReportUrl, buildCrashIssueUrl } from './lib/crashReport'; import { pickCodeWorkspaceFile, pickRepoDirectories } from './lib/dialog'; import { editorTemplate, osType, terminalTemplate } from './lib/integrations'; import { t } from './lib/i18n'; +import { LFS_ACTIONS } from './lib/lfs'; +import { SUBMODULE_ACTIONS, type SubmoduleDialogAction } from './lib/submodules'; +import type { LfsAction } from './lib/types'; import { plural } from './lib/plural'; import { concatPatches, patchesToMarkdown } from './lib/patchExport'; import { buildReviewFeedback, collectFeedbackFiles } from './lib/reviewExport'; @@ -55,6 +58,7 @@ import type { SettingsSectionId } from './views/SettingsDialog'; import { StashDialog } from './views/StashDialog'; import { BranchDialog } from './views/BranchDialog'; import { TagDialog } from './views/TagDialog'; +import { TagVerificationDialog } from './views/TagVerificationDialog'; import { MergeDialog } from './views/MergeDialog'; import { RemoteDialog, type RemoteDialogMode } from './views/RemoteDialog'; import { FileEntryDialog } from './views/FileEntryDialog'; @@ -95,12 +99,15 @@ import { WorktreeMergeDialog } from './views/WorktreeMergeDialog'; import { ForcePushDialog } from './views/ForcePushDialog'; import { BranchNetworkDialog, type BranchNetworkDialogMode } from './views/BranchNetworkDialog'; import { CommandPalette, type PaletteAction } from './views/Palette'; +import { USER_ACTION_EVENT, userActionPalette, type ActionRequest } from './lib/userActions'; import { RepoSwitcher } from './views/RepoSwitcher'; import type { CrashCheck, BranchPushRequest, FileDiff, Progress, + CloneOptions, + HistoryExpansion, PullMode, PushMode, RepoMeta, @@ -114,11 +121,24 @@ const waitForPaint = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); const CloneDialog = lazy(() => import('./views/CloneDialog').then((m) => ({ default: m.CloneDialog }))); +const PublishRepoDialog = lazy(() => import('./views/PublishRepoDialog').then((m) => ({ default: m.PublishRepoDialog }))); + +const CloneScopeDialog = lazy(() => import('./views/CloneScopeDialog').then((m) => ({ default: m.CloneScopeDialog }))); +const SparseCheckoutDialog = lazy(() => import('./views/SparseCheckoutDialog').then((m) => ({ default: m.SparseCheckoutDialog }))); const InitRepoDialog = lazy(() => import('./views/InitRepoDialog').then((m) => ({ default: m.InitRepoDialog }))); const SettingsDialog = lazy(() => import('./views/SettingsDialog').then((m) => ({ default: m.SettingsDialog }))); const BranchCleanupDialog = lazy(() => import('./views/BranchCleanupDialog').then((m) => ({ default: m.BranchCleanupDialog }))); const RebaseEditor = lazy(() => import('./views/RebaseEditor').then((m) => ({ default: m.RebaseEditor }))); const MaintenanceDialog = lazy(() => import('./views/MaintenanceDialog').then((m) => ({ default: m.MaintenanceDialog }))); +const InterchangeDialog = lazy(() => import('./views/InterchangeDialog').then((m) => ({ default: m.InterchangeDialog }))); +const GitflowDialog = lazy(() => import('./views/GitflowDialog').then((m) => ({ default: m.GitflowDialog }))); +const AdvancedRefsDialog = lazy(() => import('./views/AdvancedRefsDialog').then((m) => ({ default: m.AdvancedRefsDialog }))); +const BisectDialog = lazy(() => import('./views/BisectDialog').then((m) => ({ default: m.BisectDialog }))); + +const UserActionDialog = lazy(() => import('./views/UserActionDialog').then((m) => ({ default: m.UserActionDialog }))); + +const LfsDialog = lazy(() => import('./views/LfsDialog').then((m) => ({ default: m.LfsDialog }))); +const SubmoduleDialog = lazy(() => import('./views/SubmoduleDialog').then((m) => ({ default: m.SubmoduleDialog }))); const WorkspaceManagerDialog = lazy(() => import('./views/WorkspaceManagerDialog').then((m) => ({ default: m.WorkspaceManagerDialog }))); const PullRequests = lazy(() => import('./views/PullRequests').then((m) => ({ default: m.PullRequests }))); @@ -291,7 +311,6 @@ export function App() { const submodules = useRepo((s) => s.submodules); const stashApply = useRepo((s) => s.stashApply); const stashPop = useRepo((s) => s.stashPop); - const submoduleUpdate = useRepo((s) => s.submoduleUpdate); const pruneWorktrees = useRepo((s) => s.pruneWorktrees); const baseline = useRepo((s) => s.baseline); const setBaseline = useRepo((s) => s.setBaseline); @@ -322,6 +341,8 @@ export function App() { const customWorkspaceReady = customRestored && customWorkspaceId === workbenchWorkspaceId; const activePullRequestKey = usePullRequests((s) => s.active?.key ?? null); + const activePullRequestHasReviewTools = usePullRequests((s) => + s.active?.repository.provider === 'git_hub' || s.active?.repository.provider === 'azure_dev_ops'); const activePullRequestFollowed = usePullRequests((s) => activePullRequestKey ? Boolean(s.followed[activePullRequestKey]) : false); const activePullRequestCanUpdateBranch = usePullRequests((s) => @@ -335,12 +356,26 @@ export function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState('appearance'); const [cloneOpen, setCloneOpen] = useState(false); + const [publishRepoOpen, setPublishRepoOpen] = useState(false); + useEffect(() => { + const open = () => setPublishRepoOpen(true); + window.addEventListener('strand:publish-repository', open); + return () => window.removeEventListener('strand:publish-repository', open); + }, []); + const [cloneScopePath, setCloneScopePath] = useState(null); + const [sparsePath, setSparsePath] = useState(null); + useEffect(() => { + const open = () => setSparsePath(useRepo.getState().activePath); + window.addEventListener('strand:open-sparse-checkout', open); + return () => window.removeEventListener('strand:open-sparse-checkout', open); + }, []); const [initRepoOpen, setInitRepoOpen] = useState(false); // null = closed; otherwise the flavour the dialog opens in (snapshot vs stash). const [stashDialog, setStashDialog] = useState<{ snapshot: boolean; keepIndex: boolean } | null>(null); const stashDialogRequest = useRepo((s) => s.stashDialogRequest); const clearStashDialogRequest = useRepo((s) => s.clearStashDialogRequest); // null = closed; otherwise the tag target (revspec, null ⇒ HEAD) + its label. + const [tagVerification, setTagVerification] = useState<{ path: string; name: string | null } | null>(null); const [tagDialog, setTagDialog] = useState<{ target: string | null; label: string } | null>(null); const [branchDialog, setBranchDialog] = useState<{ start: string | null; @@ -351,6 +386,21 @@ export function App() { // null = closed; otherwise which remote-management flavour (add/rename/url). const [remoteDialog, setRemoteDialog] = useState(null); const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [interchangePath, setInterchangePath] = useState(null); + const [gitflowPath, setGitflowPath] = useState(null); + const [advancedRefs, setAdvancedRefs] = useState<{ path: string; mode: 'notes' | 'replace' | 'retarget' | 'reannotate'; tag?: string } | null>(null); + const [bisectPath, setBisectPath] = useState(null); + + const userActions = useSettings((state) => state.userActions); + const [userActionRequest, setUserActionRequest] = useState<(ActionRequest & { key: string }) | null>(null); + useEffect(() => { + const open = (event: Event) => setUserActionRequest({ ...(event as CustomEvent).detail, key: crypto.randomUUID() }); + window.addEventListener(USER_ACTION_EVENT, open); + return () => window.removeEventListener(USER_ACTION_EVENT, open); + }, []); + + const [lfsAction, setLfsAction] = useState<{ repoPath: string; action: LfsAction['action'] } | null>(null); + const [submoduleDialog, setSubmoduleDialog] = useState<{ repoPath: string; path: string; action: SubmoduleDialogAction } | null>(null); const [fileEntryDialog, setFileEntryDialog] = useState<{ dir: string; directory: boolean } | null>(null); // null = closed; otherwise the branch to rename. const [renameBranchDialog, setRenameBranchDialog] = useState<{ name: string } | null>(null); @@ -488,6 +538,11 @@ export function App() { setSettingsSection(section); setSettingsOpen(true); }, []); + useEffect(() => { + const open = () => openSettingsAt('git'); + window.addEventListener('strand:open-git-settings', open); + return () => window.removeEventListener('strand:open-git-settings', open); + }, [openSettingsAt]); useEffect(() => { void restoreWorkbench(workbenchWorkspaceId); @@ -722,7 +777,7 @@ export function App() { // same popup (one op id) switches in place from "Cloning" to "Opening" — no // flicker. The Clone dialog closes the moment this starts; failures surface // as a toast (there's no dialog to return to). - const runClone = useCallback(async (url: string, dest: string) => { + const runClone = useCallback(async (url: string, dest: string, options: CloneOptions) => { const id = ++opGen.current; const cancelId = nextOpId(); setCloneCancelId(cancelId); @@ -757,7 +812,7 @@ export function App() { } const detail = pct != null ? `${p.phase || 'Working'} ${pct}%` : p.raw || p.phase || 'Cloning…'; setOpProgress((cur) => (cur && cur.id === id && cur.kind === 'clone' ? { ...cur, percent: pct, detail, eta } : cur)); - }, cancelId); + }, cancelId, options); clonedPath = res.path; } catch (e) { setCloneCancelId(null); @@ -861,6 +916,25 @@ export function App() { void setActiveTab(next.path); }, []); + const onExpandHistory = useCallback(async (path: string, remote: string, expansion: HistoryExpansion) => { + if (syncing || pulling || pushing) throw new Error('Another network operation is running.'); + setSyncing(true); + setNetProgress('Downloading history…'); + const opId = nextOpId(); + setNetOpId(opId); + try { + await tauri.repoExpandHistory(path, remote, expansion, (p) => setNetProgress(p.raw), opId); + showToast('History download completed'); + } finally { + setSyncing(false); + setNetProgress(null); + setNetOpId(null); + if (useRepo.getState().activePath === path) { + await Promise.all([useRepo.getState().refreshSnapshot(), useRepo.getState().refreshLog()]); + } + } + }, [syncing, pulling, pushing, nextOpId, showToast]); + const onFetch = useCallback(async (prune?: boolean) => { if (syncing || pulling || pushing) return; setSyncing(true); @@ -1161,6 +1235,10 @@ export function App() { push: () => { void onPush(); }, openInEditor, openInTerminal, + openInterchange: () => { const path = useRepo.getState().activePath; if (path) setInterchangePath(path); }, + openGitflow: () => { const path = useRepo.getState().activePath; if (path) setGitflowPath(path); }, + openAdvancedRefs: () => { const path = useRepo.getState().activePath; if (path) setAdvancedRefs({ path, mode: 'notes' }); }, + openBisect: () => { const path = useRepo.getState().activePath; if (path) setBisectPath(path); }, }; const hasRepo = Boolean(meta); useEffect(() => { @@ -1469,6 +1547,7 @@ export function App() { // Files — explicit palette selection opens a pinned Work document. for (const f of workTree) { + if (f.excluded) continue; out.push({ id: `file:${f.path}`, label: f.path, @@ -1540,8 +1619,8 @@ export function App() { keywords: `submodule init update ${sm.path}`, meta: sm.status, run: () => { - void submoduleUpdate([sm.path], true, true).catch((e) => - showToast(`Submodule update failed: ${errMessage(e)}`, 'error')); + setPaletteOpen(false); + setSubmoduleDialog({ repoPath: meta!.path, path: sm.path, action: 'update' }); }, }); } @@ -1549,7 +1628,7 @@ export function App() { return out; }, [paletteOpen, meta, refs, workTree, commits, stashes, submodules, checkout, createBranch, revealInGraph, selectCommit, selectFile, showToast, showWorkbenchWork, - stashApply, stashPop, submoduleUpdate]); + stashApply, stashPop]); const runCustomAction = useCallback(( action: (state: ReturnType) => void, @@ -1720,6 +1799,20 @@ export function App() { }, } satisfies PaletteAction] : []), ...(view === 'pull-requests' ? [ + { + id: 'pull-request-load-more', + label: 'Pull Requests: load next data page', + group: 'Actions', + keywords: 'pr pagination inbox reviews threads replies checks more partial', + run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-load-more')), + } satisfies PaletteAction, + { + id: 'pull-request-cancel-read', + label: 'Pull Requests: cancel loading page', + group: 'Actions', + keywords: 'pr stop pagination', + run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-cancel-read')), + } satisfies PaletteAction, { id: 'pull-request-search', label: 'Pull Requests: search…', @@ -1727,7 +1820,21 @@ export function App() { keywords: 'pr inbox find filter authored completed', run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-search')), } satisfies PaletteAction, - ...(activePullRequestKey ? [{ + ...(activePullRequestKey ? [ + ...(activePullRequestHasReviewTools ? [ + { id: 'pull-request-review-tools-compare', label: 'Pull Requests: compare reviewed head…', group: 'Actions', keywords: 'pr review evolution iteration boundary feedback suggestions local', run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-review-tools', { detail: 'compare' })) } satisfies PaletteAction, + { id: 'pull-request-review-tools-mark', label: 'Pull Requests: mark head reviewed…', group: 'Actions', keywords: 'pr review evolution iteration boundary feedback suggestions local', run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-review-tools', { detail: 'mark' })) } satisfies PaletteAction, + { id: 'pull-request-review-tools-feedback', label: 'Pull Requests: export unresolved feedback…', group: 'Actions', keywords: 'pr review evolution iteration boundary feedback suggestions local', run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-review-tools', { detail: 'feedback' })) } satisfies PaletteAction, + { id: 'pull-request-review-tools-suggestions', label: 'Pull Requests: preview suggestions…', group: 'Actions', keywords: 'pr review evolution iteration boundary feedback suggestions local', run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-review-tools', { detail: 'suggestions' })) } satisfies PaletteAction, + { + id: 'pull-request-completion', + label: 'Pull Requests: merge queue or auto-complete…', + group: 'Actions', + keywords: 'pr github azure enable cancel auto merge queue status policies', + run: () => window.dispatchEvent(new CustomEvent('strand:pull-request-completion')), + } satisfies PaletteAction, + ] : []), + { id: 'pull-request-merge', label: 'Pull Requests: merge or mark ready…', group: 'Actions', @@ -1874,7 +1981,21 @@ export function App() { })(), ] : []), + { id: 'publish-repository', label: 'Publish repository…', group: 'Actions', keywords: 'create hosted github gitlab bitbucket repository account organization visibility initial push', run: () => setPublishRepoOpen(true) }, { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, + { id: 'git-interchange', label: 'Patches, mailboxes & bundles…', group: 'Actions', keywords: 'import export apply index working tree am continue skip abort author bundle verify prerequisites', run: () => { setPaletteOpen(false); setInterchangePath(meta.path); } }, + { id: 'git-notes', label: 'Git notes…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'notes' }); } }, + { id: 'git-replace', label: 'Replace refs…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'replace' }); } }, + { id: 'git-retarget', label: 'Retarget tag…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'retarget' }); } }, + { id: 'git-reannotate', label: 'Re-annotate tag…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'reannotate' }); } }, + { id: 'gitflow', label: 'Git-flow workflows…', group: 'Actions', keywords: 'AVH feature release hotfix start finish resume configuration', run: () => { setPaletteOpen(false); setGitflowPath(meta.path); } }, + { id: 'git-bisect', label: 'Guided bisect…', group: 'Actions', keywords: 'good bad skip regression culprit test resume reset', run: () => { setPaletteOpen(false); setBisectPath(meta.path); } }, + + { id: 'clone-scope', label: 'Repository history and downloads…', group: 'Actions', keywords: 'clone shallow partial filter deepen unshallow single branch', run: () => setCloneScopePath(meta.path) }, + { id: 'sparse-checkout', label: 'Sparse checkout…', group: 'Actions', keywords: 'cone directories select inspect change disable excluded sparse index', run: () => setSparsePath(meta.path) }, + + ...SUBMODULE_ACTIONS.map(([action, label]) => ({ id: `submodule-${action}`, label: `Submodules: ${label}…`, group: 'Actions', keywords: 'submodule lifecycle url nested status add remove deinit sync', run: () => { setPaletteOpen(false); setSubmoduleDialog({ repoPath: meta.path, path: '', action }); } } satisfies PaletteAction)), + ...LFS_ACTIONS.map(([action, label]) => ({ id: `lfs-${action}`, label: `Git LFS: ${label}…`, group: 'Actions', keywords: 'large file storage lfs objects filters patterns transfers locks', run: () => { setPaletteOpen(false); setLfsAction({ repoPath: meta.path, action }); } } satisfies PaletteAction)), { id: 'repository-maintenance', label: 'Repository maintenance…', group: 'Actions', keywords: 'git gc fsck integrity optimize activity log command output', run: () => { setPaletteOpen(false); setMaintenanceOpen(true); @@ -1905,6 +2026,7 @@ export function App() { }, } satisfies PaletteAction] : []), ]), + { id: 'verify-tag', label: 'Verify tag signature…', group: 'Actions', keywords: 'gpg ssh signed tag trust', run: () => { const path = useRepo.getState().activePath; if (path) setTagVerification({ path, name: null }); } }, { id: 'tag', label: 'Create tag…', group: 'Actions', run: () => setTagDialog({ target: null, label: 'HEAD' }) }, { id: 'push-tags', label: 'Push all tags', group: 'Actions', keywords: 'push upload publish tags remote', run: onPushAllTags }, { id: 'fetch', label: 'Fetch', group: 'Actions', shortcut: keyHint('fetch'), keywords: 'fetch remote refs download', run: onFetch }, @@ -1946,7 +2068,9 @@ export function App() { base.push( { id: 'settings', label: 'Settings…', group: 'Actions', shortcut: keyHint('settings'), keywords: 'preferences shortcuts keyboard config options', run: () => openSettingsAt('appearance') }, { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, + { id: 'settings-git', label: 'Settings: Repository identity and signing', group: 'Actions', keywords: 'author committer name email local override config gpg ssh key sign tags', run: () => openSettingsAt('git') }, { id: 'settings-ai', label: 'Settings: AI', group: 'Actions', keywords: 'ai chatgpt codex claude commit message suggest login', run: () => openSettingsAt('ai') }, + { id: 'settings-hosting', label: 'Settings: Hosting', group: 'Actions', keywords: 'github enterprise custom host gitlab bitbucket azure provider authentication account', run: () => openSettingsAt('hosting') }, { id: 'settings-plugins', label: 'Settings: Plugins', group: 'Actions', keywords: 'plugins marketplace extensions workbench surfaces install', run: () => openSettingsAt('plugins') }, { id: 'heroi-new-conversation', label: 'Heroi: New conversation', group: 'Actions', keywords: 'heroi agent chat claude codex cursor', run: () => window.dispatchEvent(new CustomEvent(HEROI_NEW_CONVERSATION_EVENT)) }, { @@ -1972,7 +2096,7 @@ export function App() { { id: 'toggle-sidebar', label: sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar', group: 'Actions', shortcut: keyHint('toggle-sidebar'), keywords: 'sidebar collapse expand hide show panel', run: toggleSidebar }, ); // Surface "Abort" in the palette only while an op is actually paused. - if (meta?.operation) { + if (meta?.operation && meta.operation !== 'bisect' && meta.operation !== 'mailbox') { base.push({ id: 'abort-op', label: `Abort ${meta.operation}`, @@ -2019,7 +2143,9 @@ export function App() { icon: 'history', run: () => { void openByPath(r.path); }, })); - return [...base, ...repoActions, ...workspaceActions, ...recentActions]; + return [...base, { id: 'manage-user-actions', label: 'Manage user actions…', group: 'Actions', + keywords: 'custom scripts executable templates repository ref file', run: () => openSettingsAt('user-actions') } satisfies PaletteAction, + ...(paletteOpen ? userActionPalette(userActions, () => showToast('Selection changed. Open Quick Launch again.', 'error')) : []), ...repoActions, ...workspaceActions, ...recentActions]; }, [setView, selectFile, onFetch, onSync, onPull, onPush, onPushAllTags, openViaDialog, openByPath, setTheme, recents, showToast, meta, abortOperation, requestCommitSearch, requestDiffSearch, requestSuggestCommitMessage, requestSelectSinceBaseline, openInEditor, openInTerminal, openSettingsAt, @@ -2028,9 +2154,9 @@ export function App() { unstagedCount, stagedCount, copyFreshDiffs, reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab, view, workspaces, activeWorkspaceId, importCodeWorkspaceFlow, pruneWorktrees, - activePullRequestKey, activePullRequestFollowed, activePullRequestCanUpdateBranch, + activePullRequestKey, activePullRequestHasReviewTools, activePullRequestFollowed, activePullRequestCanUpdateBranch, toggleActivePullRequest, customCommands, customCommandContext, - customizeWorkbench, openWorkbench, showWorkbenchWork, workbenchEditing]); + customizeWorkbench, openWorkbench, showWorkbenchWork, workbenchEditing, userActions, paletteOpen]); const surfaceRenderers = useMemo(() => new Map setInitRepoOpen(true)} onOpenRecent={openByPath} onClone={() => setCloneOpen(true)} + onCloneScope={() => { if (meta) setCloneScopePath(meta.path); }} + onSparseCheckout={() => { if (meta) setSparsePath(meta.path); }} onCustomize={openIconDialog} onManageWorkspaces={() => setWorkspaceManagerOpen(true)} onWorktreeReview={reviewWorktreeTab} @@ -2210,7 +2338,9 @@ export function App() { onOpenRepo={openViaDialog} onOpenRecent={openByPath} onCreateStash={() => setStashDialog({ snapshot: true, keepIndex: false })} + onVerifyTag={(name) => { if (activePath) setTagVerification({ path: activePath, name }); }} onCreateTag={() => setTagDialog({ target: null, label: 'HEAD' })} + onEditTag={(tag, mode) => { if (meta) setAdvancedRefs({ path: meta.path, mode, tag }); }} onCreateBranch={(start, label) => setBranchDialog({ start, label })} onBranchFromStash={(index) => setBranchDialog({ start: `stash@{${index}}`, @@ -2221,6 +2351,8 @@ export function App() { onMerge={(source, into) => setMergeDialog({ source, into })} onInteractiveRebase={(base, label) => setRebaseDialog({ base, label })} onManageRemote={(mode) => setRemoteDialog(mode)} + onManageLfs={() => { if (meta) setLfsAction({ repoPath: meta.path, action: 'environment' }); }} + onManageSubmodules={(path = '', action = 'inspect') => { if (meta) setSubmoduleDialog({ repoPath: meta.path, path, action }); }} onRenameBranch={(name) => setRenameBranchDialog({ name })} onManageBranchNetwork={(mode) => setBranchNetworkDialog(mode)} onPull={onPull} @@ -2251,7 +2383,7 @@ export function App() { ) : view === 'work' ? ( workbenchComposed ? (
- + { if (meta) setInterchangePath(meta.path); }} onOpenBisect={() => { if (meta) setBisectPath(meta.path); }} /> )} - + { if (meta) setInterchangePath(meta.path); }} onOpenBisect={() => { if (meta) setBisectPath(meta.path); }} /> {mainSurfaceId && ( setCloneOpen(false)} onStartClone={runClone} /> )} + {publishRepoOpen && activePath && setPublishRepoOpen(false)} />} {initRepoOpen && ( setInitRepoOpen(false)} onInit={runInitRepo} /> )} @@ -2355,6 +2488,8 @@ export function App() { /> )} + {tagVerification && setTagVerification(null)} />} {tagDialog && ( setMaintenanceOpen(false)} onToast={showToast} /> )} + {interchangePath && setInterchangePath(null)} />} + {gitflowPath && setGitflowPath(null)} />} + {advancedRefs && setAdvancedRefs(null)} />} + {bisectPath && setBisectPath(null)} />} + + {userActionRequest && ( + setUserActionRequest(null)} + onManage={() => { setUserActionRequest(null); openSettingsAt('user-actions'); }} /> + )} + + {lfsAction && setLfsAction(null)} />} + {submoduleDialog && setSubmoduleDialog(null)} />} + + {cloneScopePath && { if (netOpId) void tauri.repoCancelOp(netOpId); }} + onClose={() => setCloneScopePath(null)} />} + {sparsePath && setSparsePath(null)} />} {fileEntryDialog && meta && ( , string> = { + mailbox: 'Mailbox in progress', + bisect: 'Bisect in progress', rebase: 'Rebase in progress', 'cherry-pick': 'Cherry-pick in progress', revert: 'Revert in progress', @@ -2657,7 +2813,7 @@ const OP_LABEL: Record, string> = { * conflict remains. The op clears `operation` on the next refresh, which hides * the banner. */ -function OpBanner({ onToast }: { onToast: (msg: string, kind?: 'success' | 'error') => void }) { +function OpBanner({ onToast, onOpenMailbox, onOpenBisect }: { onToast: (msg: string, kind?: 'success' | 'error') => void; onOpenMailbox: () => void; onOpenBisect: () => void }) { const operation = useRepo((s) => s.meta?.operation ?? null); const status = useRepo((s) => s.status); const abortOperation = useRepo((s) => s.abortOperation); @@ -2667,6 +2823,8 @@ function OpBanner({ onToast }: { onToast: (msg: string, kind?: 'success' | 'erro const hasConflicts = useMemo(() => status.some((s) => s.kind === 'CONFLICTED'), [status]); if (!operation) return null; + if (operation === 'bisect') return
Bisect in progressTest the selected revision, then rate it.
; + if (operation === 'mailbox') return
Mailbox in progressResolve and stage conflicts, then continue the mailbox.
; const onAbort = async () => { if (busy) return; diff --git a/ui/src/components/RepoRail.tsx b/ui/src/components/RepoRail.tsx index baf87483..4b98fe85 100644 --- a/ui/src/components/RepoRail.tsx +++ b/ui/src/components/RepoRail.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from './Icon'; +import { openUserAction } from '../lib/userActions'; import { WorkspaceSwitcher } from './WorkspaceSwitcher'; import { formatBinding } from '../lib/keys'; import { useRepo } from '../stores/repo'; @@ -228,6 +229,9 @@ function RailContextMenu({ )} + )} + +
} {selectedCommit && (
Files at {selectedCommit.slice(0, 7)} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index a1b1c73f..3fecc365 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ContextMenu, type MenuItem } from './ContextMenu'; +import { userActionMenu } from '../lib/userActions'; import { Icon, type IconName } from './Icon'; import { copyToClipboard } from './PierreTree'; import { RepositoryFiles } from './RepositoryFiles'; @@ -79,6 +80,8 @@ function SideSection({ label, collapsed, onToggle, count, action }: SectionProps } interface SidebarProps { + onManageLfs: () => void; + onManageSubmodules: (path?: string, action?: import('../lib/submodules').SubmoduleDialogAction) => void; onOpenWorkbench: () => void; onOpenWorkSurface: () => void; onOpenRepo: () => void; @@ -87,6 +90,9 @@ interface SidebarProps { onCreateStash: () => void; /** Open the New-tag dialog targeting HEAD. */ onCreateTag: () => void; + onEditTag: (name: string, kind: 'retarget' | 'reannotate') => void; + + onVerifyTag: (name: string) => void; /** Open the New-branch dialog from `start` (`null` ⇒ HEAD); `label` is the * human name shown in the blurb. */ onCreateBranch: (start: string | null, label: string) => void; @@ -176,7 +182,7 @@ function sortTree(node: TreeNode, leafCmp: (a: T, b: T) => number): void { // ─── component ────────────────────────────────────────────────────────── -export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { +export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onEditTag, onVerifyTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { const view = useRepo((s) => s.view); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); @@ -224,7 +230,6 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen const stashPop = useRepo((s) => s.stashPop); const stashDrop = useRepo((s) => s.stashDrop); const submodules = useRepo((s) => s.submodules); - const submoduleUpdate = useRepo((s) => s.submoduleUpdate); const worktrees = useRepo((s) => s.worktrees); const openWorktree = useRepo((s) => s.openWorktree); const removeWorktree = useRepo((s) => s.removeWorktree); @@ -501,6 +506,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen }; const branchMenu = (b: Branch): MenuItem[] => { + const actionItem = userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: b.full_name, oid: b.target } }); const newBranchItem: MenuItem = { label: 'New branch from here…', icon: 'plus', @@ -544,6 +550,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen { label: 'Force with lease…', danger: true, onSelect: onForcePush }, ], }, + actionItem, newBranchItem, newWorktreeItem, renameItem, @@ -594,12 +601,13 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen { label: 'Copy commit SHA', icon: 'file', onSelect: () => { void copyToClipboard(b.target); onToast('Commit SHA copied'); } }, ); items.push({ label: 'Delete branch', icon: 'trash', danger: true, confirm: true, onSelect: () => void runBranchOp(() => deleteBranch(b.name, true)) }); + items.push(actionItem); return items; }; const remoteMenu = (rb: RemoteBranch): MenuItem[] => { const local = localByUpstream.get(rb.name); - const items: MenuItem[] = []; + const items: MenuItem[] = [userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: rb.full_name, oid: rb.target } })]; items.push({ label: 'Fetch this branch', icon: 'arrow-down', onSelect: () => onFetchBranch(rb) }); if (currentBranch) { items.push({ @@ -745,6 +753,10 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen const tagMenu = (tg: Tag): MenuItem[] => { const items: MenuItem[] = [ + { label: 'Retarget tag…', onSelect: () => onEditTag(tg.name, 'retarget') }, + { label: 'Re-annotate tag…', onSelect: () => onEditTag(tg.name, 'reannotate') }, + + userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: tg.full_name, oid: tg.target } }), { label: 'Checkout', icon: 'branch', onSelect: () => void runBranchOp(() => checkoutCommit(tg.target)) }, { label: 'New branch from here…', icon: 'plus', onSelect: () => onCreateBranch(tg.full_name, tg.name) }, { label: 'New worktree from here…', icon: 'worktree', onSelect: () => onCreateWorktree({ ref: tg.full_name, label: tg.name }) }, @@ -770,6 +782,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen { label: 'Copy tag name', icon: 'file', onSelect: () => { void copyToClipboard(tg.name); onToast('Tag name copied'); } }, { label: 'Copy commit SHA', icon: 'file', onSelect: () => { void copyToClipboard(tg.target); onToast('Commit SHA copied'); } }, ); + items.push({ label: 'Verify tag signature…', icon: 'tag', onSelect: () => onVerifyTag(tg.name) }); items.push({ label: 'Delete tag', icon: 'trash', danger: true, confirm: true, onSelect: () => void runBranchOp(() => deleteTag(tg.name)) }); return items; }; @@ -795,19 +808,6 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen if (!meta || !sub.initialized) return; onOpenRecent(`${meta.path}/${sub.path}`); }; - // `git submodule update` (always --init --recursive) for the given paths - // (empty ⇒ all). Surfaces start + result via a toast. - const runSubmoduleUpdate = (paths: string[], label: string) => { - void (async () => { - onToast(`Updating ${label}…`); - try { - await submoduleUpdate(paths, true, true); - onToast(`Updated ${label}`); - } catch (e) { - onToast(`Submodule update failed: ${errMessage(e)}`, 'error'); - } - })(); - }; const submoduleMenu = (sub: Submodule): MenuItem[] => { const items: MenuItem[] = []; if (sub.initialized) { @@ -816,9 +816,10 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen items.push({ label: sub.initialized ? 'Update' : 'Init & update', icon: 'arrow-down', - onSelect: () => runSubmoduleUpdate([sub.path], leafName(sub.path)), + onSelect: () => onManageSubmodules(sub.path, 'update'), }); items.push({ label: 'Copy path', icon: 'file', onSelect: () => void copyToClipboard(sub.path) }); + items.push({ label: 'Manage / inspect nested modules…', icon: 'submodule', onSelect: () => onManageSubmodules(sub.path) }); return items; }; @@ -942,7 +943,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen if (wt) void openWorktree(wt.path); else void runBranchOp(() => checkout(b.name)); }} - onSelect={() => revealInGraph(b.target)} + onSelect={() => { revealInGraph(b.target); useRepo.getState().selectRef(b.full_name); }} onMenu={(x, y) => openMenu(x, y, branchMenu(b))} /> ); @@ -974,7 +975,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen : createBranch(localBranchName(rb), rb.name, true), ) } - onSelect={() => revealInGraph(rb.target)} + onSelect={() => { revealInGraph(rb.target); useRepo.getState().selectRef(rb.full_name); }} onMenu={(x, y) => openMenu(x, y, remoteMenu(rb))} /> ); @@ -989,7 +990,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen meta={tg.annotated ? 'annotated' : undefined} title={`${leafName(tg.name)} — click to reveal, double-click to check out`} onActivate={() => void runBranchOp(() => checkoutCommit(tg.target))} - onSelect={() => revealInGraph(tg.target)} + onSelect={() => { revealInGraph(tg.target); useRepo.getState().selectRef(tg.full_name); }} onMenu={(x, y) => openMenu(x, y, tagMenu(tg))} /> ); @@ -1016,6 +1017,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen /> )}
+ {meta && } onManageRemote({ kind: 'add' }) }} /> + {sections.remotes && window.dispatchEvent(new CustomEvent('strand:publish-repository'))} />} {sections.remotes && renderTreeChildren(remoteTree, 0, collapsed, toggleCollapsed, renderRemoteLeaf, 'remotes', { folderIcon: 'remote', @@ -1167,11 +1170,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen collapsed={!sections.submods} onToggle={() => toggle('submods')} count={filteredSubmodules.length} - action={ - submodules.length > 0 - ? { icon: 'sync', title: 'Update all submodules', onClick: () => runSubmoduleUpdate([], 'all submodules') } - : undefined - } + action={{ icon: 'plus', title: 'Manage submodules', onClick: () => onManageSubmodules() }} /> {sections.submods && filteredSubmodules.length === 0 && (
No submodules.
diff --git a/ui/src/components/SigningChoice.tsx b/ui/src/components/SigningChoice.tsx new file mode 100644 index 00000000..389feb4d --- /dev/null +++ b/ui/src/components/SigningChoice.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { errMessage, tauri } from '../lib/tauri'; +import type { SigningMode, SigningSettings } from '../lib/types'; + +export function SigningChoice({ path, kind, annotated = false, settingsLink = true, value, disabled, onChange }: { + path: string; kind: 'commit' | 'tag'; annotated?: boolean; settingsLink?: boolean; + value: SigningMode; disabled: boolean; onChange: (value: SigningMode) => void; +}) { + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + let active = true; + let sequence = 0; + const load = () => { + const request = ++sequence; + void tauri.repoSigningSettings(path).then((result) => { + if (active && request === sequence) { setSettings(result); setError(null); } + }).catch((e) => { if (active && request === sequence) setError(errMessage(e)); }); + }; + load(); + window.addEventListener('strand:git-config-changed', load); + window.addEventListener('focus', load); + return () => { active = false; window.removeEventListener('strand:git-config-changed', load); window.removeEventListener('focus', load); }; + }, [path]); + const inherited = settings && (kind === 'commit' ? settings.commit_sign + : settings.tag_sign || (annotated && settings.tag_force_annotated)); + return
+ + {settingsLink && } + {error && {error}} +
; +} diff --git a/ui/src/components/Topbar.tsx b/ui/src/components/Topbar.tsx index 356b4fdf..6c7a68fc 100644 --- a/ui/src/components/Topbar.tsx +++ b/ui/src/components/Topbar.tsx @@ -42,6 +42,8 @@ interface Props { onOpenRecent: (path: string) => void; /** Open the clone dialog (tabs-mode `+` menu). */ onClone: () => void; + onCloneScope: () => void; + onSparseCheckout: () => void; /** Open the icon-customization dialog for a repo tab. */ onCustomize: (path: string) => void; /** Open the workspace manager dialog (tabs-mode switcher). */ @@ -81,6 +83,8 @@ export function Topbar({ onInitRepo, onOpenRecent, onClone, + onCloneScope, + onSparseCheckout, onCustomize, onManageWorkspaces, onWorktreeReview, @@ -166,7 +170,9 @@ export function Topbar({ { label: 'Force with lease…', icon: 'arrow-up', danger: true, onSelect: onForcePush }, ], }, - ], [fetchPrune, networkBusy, onFetch, onForcePush, onPull, onPush, onPushAllTags, onSetFetchPrune, onSetPullAutostash, onSetPullMode, pullAutostash, pullMode, pullModeLabel]); + { label: 'Repository history and downloads…', disabled: networkBusy, onSelect: onCloneScope }, + { label: 'Sparse checkout…', disabled: networkBusy, onSelect: onSparseCheckout }, + ], [fetchPrune, networkBusy, onCloneScope, onSparseCheckout, onFetch, onForcePush, onPull, onPush, onPushAllTags, onSetFetchPrune, onSetPullAutostash, onSetPullMode, pullAutostash, pullMode, pullModeLabel]); const inTauri = isTauri(); // macOS lets the OS draw the traffic lights over our toolbar (`titleBarStyle: diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 4298b70f..06e74ea3 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -69,6 +69,17 @@ export const handlers: Record = { microsoft_store_update_available: () => false, microsoft_store_open_product: () => unavailable('The Microsoft Store'), crash_report_check: () => ({ path: '', len: 0, entry: null }), + repo_signing_settings: () => ({ effective: {}, local: {}, worktree: {}, worktree_enabled: false, + commit_sign: false, tag_sign: false, tag_force_annotated: false }), + repo_set_signing_config: () => unavailable('Signing configuration'), + repo_tag_verify: () => unavailable('Tag signature verification'), + repo_identity: () => { + const source = (value: string) => ({ value, scope: 'demo', origin: 'Demo identity' }); + const identity = { identity: `${repo.identity.name} <${repo.identity.email}>`, error: null, + name_source: source(repo.identity.name), email_source: source(repo.identity.email) }; + return { author: identity, committer: identity, local: { name: null, email: null } }; + }, + repo_set_identity: () => unavailable('Repository identity overrides'), git_global_identity: () => ({ name: repo.identity.name, email: repo.identity.email }), git_set_global_identity: ({ name, email }) => { repo.identity = { name: str(name), email: str(email) }; }, workspace_file_read: () => unavailable('Reading .code-workspace files'), @@ -82,6 +93,13 @@ export const handlers: Record = { azdo_profile_set_pat: () => unavailable('Azure DevOps profiles'), azdo_profile_clear_pat: () => unavailable('Azure DevOps profiles'), azdo_profile_test: () => unavailable('Azure DevOps profiles'), + repo_hosting_providers: () => [], + repo_set_hosting_provider: () => unavailable('Hosted repository setup'), + hosted_publish_accounts: () => unavailable('Hosted repository setup'), + hosted_publish_state: () => null, + hosted_publish_preview: () => unavailable('Hosted repository setup'), + hosted_publish_advance: () => unavailable('Hosted repository setup'), + hosted_publish_forget: () => unavailable('Hosted repository setup'), hosting_connection_status: () => ({ github: { installed: true, connected: true, account: 'dana', detail: 'gh 2.62.0 · signed in as dana (demo)' }, azure_dev_ops: { installed: false, connected: false, account: null, detail: 'az CLI not installed' }, @@ -107,6 +125,11 @@ export const handlers: Record = { repo_refs: (a) => repo.refs(wtOf(a)), repo_submodules: () => [], repo_submodule_update: () => unavailable('Submodule updates'), + repo_clone_scope: () => ({ shallow: false, remotes: [{ name: 'origin', filter: null, fetch_refspecs: ['+refs/heads/*:refs/remotes/origin/*'] }] }), + repo_expand_history: () => unavailable('History downloads'), + repo_sparse_checkout: () => unavailable('Sparse checkout'), + repo_set_sparse_checkout: () => unavailable('Sparse checkout'), + repo_disable_sparse_checkout: () => unavailable('Sparse checkout'), repo_maintenance: async ({ task }) => { await sleep(600); const command = task === 'garbage-collect' ? 'git gc' : task === 'integrity-check' ? 'git fsck --no-dangling' : 'git maintenance run'; @@ -208,8 +231,9 @@ export const handlers: Record = { repo_discard_many: (a) => { const wt = wtOf(a); for (const f of a.files as string[]) repo.discard(wt, f); }, repo_apply_patch: (a) => repo.applyPatchTo(wtOf(a), str(a.patch), a.target as 'index' | 'index_reverse' | 'workdir_reverse' | 'workdir'), repo_commit: (a) => { + if (a.signing === 'sign') return unavailable('Commit signing'); const c = repo.commitIndex(wtOf(a), str(a.subject), a.body == null ? null : str(a.body), Boolean(a.amend)); - return { oid: c.hash, amended: Boolean(a.amend) }; + return { oid: c.hash, amended: Boolean(a.amend), output: 'Demo commit created.' }; }, // ---- branches / tags / remotes ----------------------------------------- @@ -255,7 +279,10 @@ export const handlers: Record = { repo_remote_set_urls: (a) => { const r = repo.remotes.find((x) => x.name === str(a.name)); if (r) { r.url = str(a.url); r.push_url = a.pushUrl == null ? null : str(a.pushUrl); } }, repo_remote_set_default: (a) => { for (const r of repo.remotes) r.is_default = r.name === str(a.name); }, repo_remote_tags: () => repo.tags.map((t) => t.name), - repo_tag_create: (a) => repo.tagCreate(wtOf(a), str(a.name), a.target == null ? null : str(a.target), a.message == null ? null : str(a.message), Boolean(a.force)), + repo_tag_create: (a) => { + if (a.signing === 'sign') return unavailable('Tag signing'); + return repo.tagCreate(wtOf(a), str(a.name), a.target == null ? null : str(a.target), a.message == null ? null : str(a.message), Boolean(a.force)); + }, repo_tag_delete: (a) => repo.tagDelete(str(a.name)), repo_tag_push: async (a) => { await streamProgress(a.onEvent as Channel, ['Writing objects'], 'done'); diff --git a/ui/src/lib/advancedRefs.ts b/ui/src/lib/advancedRefs.ts new file mode 100644 index 00000000..0304f8ef --- /dev/null +++ b/ui/src/lib/advancedRefs.ts @@ -0,0 +1,7 @@ +export interface ObjectSummary { oid: string; kind: string; subject: string } +export interface AdvancedRefs { notes_refs: string[]; notes_tip: string | null; notes: Array<{ object: string; note: string }>; notes_truncated: boolean; replacements: Array<{ original: string; replacement: string }>; replacements_truncated: boolean } +export interface GitNote { target: ObjectSummary; ref_tip: string | null; message: string | null } +export interface ReplaceReview { original: ObjectSummary; replacement: ObjectSummary; previous: string | null } +export type TagEditKind = 'retarget' | 'reannotate'; +export interface TagEditReview { name: string; ref_oid: string; current: ObjectSummary; proposed: ObjectSummary; annotation: string | null; signed: boolean; changed_files: number; remotes: string[] } +export interface PublishedTag { remote: string; oid: string | null } diff --git a/ui/src/lib/bisect.test.ts b/ui/src/lib/bisect.test.ts new file mode 100644 index 00000000..ad761e5c --- /dev/null +++ b/ui/src/lib/bisect.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest'; +import { bisectRatingBlock, type BisectState } from './bisect'; +const state: BisectState = { active: true, token: 'a', original: 'main', original_tip: 'tip', current: 'candidate', subject: 'Change', expected: 'candidate', good_term: 'good', bad_term: 'bad', remaining: 8, remaining_truncated: false, range_error: '', culprit: null, ambiguous: false, no_checkout: false, clean: true, log: '' }; +it('allows the selected clean revision but blocks an external checkout', () => { + expect(bisectRatingBlock(state)).toBeNull(); + expect(bisectRatingBlock({ ...state, current: 'different' })).toContain('differs'); +}); +it('preserves test edits and blocks rating a completed or ambiguous result', () => { + expect(bisectRatingBlock({ ...state, clean: false })).toContain('stash'); + expect(bisectRatingBlock({ ...state, culprit: 'found' })).toContain('reset'); + expect(bisectRatingBlock({ ...state, ambiguous: true })).toContain('reset'); +}); diff --git a/ui/src/lib/bisect.ts b/ui/src/lib/bisect.ts new file mode 100644 index 00000000..4da76f01 --- /dev/null +++ b/ui/src/lib/bisect.ts @@ -0,0 +1,16 @@ +export type BisectAction = 'good' | 'bad' | 'skip' | 'reset'; +export interface BisectState { + active: boolean; token: string; original: string; original_tip: string; current: string; subject: string; + expected: string; good_term: string; bad_term: string; remaining: number; + remaining_truncated: boolean; range_error: string; culprit: string | null; ambiguous: boolean; + no_checkout: boolean; clean: boolean; log: string; +} +export interface BisectOutcome { success: boolean; output: string; state: BisectState } + +export function bisectRatingBlock(state: BisectState): string | null { + if (!state.active) return 'Start a bisect session first.'; + if (!state.clean) return 'Commit or stash test edits before changing the checkout.'; + if (state.culprit || state.ambiguous) return 'Review the result, then reset to your original checkout.'; + if (state.expected && state.current !== state.expected) return 'The checkout differs from Git’s selected test revision. Restore the expected revision before rating.'; + return null; +} diff --git a/ui/src/lib/cloneOptions.test.ts b/ui/src/lib/cloneOptions.test.ts new file mode 100644 index 00000000..fca82387 --- /dev/null +++ b/ui/src/lib/cloneOptions.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { positiveDepth } from './cloneOptions'; + +describe('positiveDepth', () => { + it('accepts bounded whole numbers and rejects lossy or invalid input', () => { + expect(positiveDepth('1')).toBe(1); + expect(positiveDepth('4294967295')).toBe(4294967295); + for (const value of ['', '0', '-1', '1.5', '2e3', '4294967296', 'NaN', '--all']) { + expect(positiveDepth(value)).toBeNull(); + } + }); +}); diff --git a/ui/src/lib/cloneOptions.ts b/ui/src/lib/cloneOptions.ts new file mode 100644 index 00000000..429d050d --- /dev/null +++ b/ui/src/lib/cloneOptions.ts @@ -0,0 +1,6 @@ +/** Match the unsigned 32-bit depth accepted by the native boundary. */ +export function positiveDepth(value: string): number | null { + if (!/^[0-9]+$/.test(value)) return null; + const depth = Number(value); + return Number.isInteger(depth) && depth > 0 && depth <= 0xffff_ffff ? depth : null; +} diff --git a/ui/src/lib/db.ts b/ui/src/lib/db.ts index a2b6c44c..07fdf239 100644 --- a/ui/src/lib/db.ts +++ b/ui/src/lib/db.ts @@ -302,6 +302,12 @@ export const repoPullMode = { * head-SHA + file-patch fingerprint, so a provider update invalidates only * the files whose review evidence is stale. */ export const pullRequestReview = { + getBoundary(reviewKey: string): Promise<{ head: string; reviewedAt: string } | null> { + return settings.get(`pull-request-boundary:${reviewKey}`); + }, + setBoundary(reviewKey: string, boundary: { head: string; reviewedAt: string }): Promise { + return settings.set(`pull-request-boundary:${reviewKey}`, boundary); + }, getViewed(reviewKey: string): Promise | null> { return settings.get>(`pull-request-reviewed:${reviewKey}`); }, diff --git a/ui/src/lib/gitflow.ts b/ui/src/lib/gitflow.ts new file mode 100644 index 00000000..e0fafef2 --- /dev/null +++ b/ui/src/lib/gitflow.ts @@ -0,0 +1,7 @@ +export interface FlowConfig { production: string; develop: string; feature: string; release: string; hotfix: string; version_tag: string } +export interface FlowState { enabled: boolean; config: FlowConfig; options: Record; branches: Record; current: string; head: string; operation: string | null; clean: boolean; conflicts: boolean; token: string } +export type FlowKind = 'feature' | 'release' | 'hotfix'; +export type FlowAction = 'start' | 'finish' | 'continue_merge' | 'abort_merge'; +export interface FlowPlan { kind: FlowKind; action: FlowAction; name: string; token: string; args: string[]; steps: string[] } +export interface FlowTool { available: boolean; version: string } +export interface FlowOutcome { success: boolean; output: string; state: FlowState } diff --git a/ui/src/lib/hostedReview.test.ts b/ui/src/lib/hostedReview.test.ts new file mode 100644 index 00000000..5a6d9b3b --- /dev/null +++ b/ui/src/lib/hostedReview.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { exportHostedFeedback, feedbackSuggestions, reviewBoundaries, suggestionBlocks } from './hostedReview'; +import type { PullRequestFeedback, PullRequestReviewThread } from './types'; +const head = 'a'.repeat(40); +const thread = (id = 'T'): PullRequestReviewThread => ({ id, path: 'src/a.ts', start_line: 2, end_line: 3, side: 'additions', is_resolved: false, is_outdated: true, iteration_id: 2, can_reply: true, can_resolve: true, can_unresolve: false, comments: [{ id: 'C', author: 'Ada', avatar_url: null, created_at: '2026-09-06', body: '```suggestion\nreplacement\n```', url: 'https://example.test/pr/1#C', is_system: false, path: 'src/a.ts' }] }); +describe('hosted review evolution', () => { + it('retains immutable reviewed boundaries after a force push and prefers my saved boundary', () => { + const saved = { head, reviewedAt: '2026-09-06' }; + const choices = reviewBoundaries(saved, [{ head, label: 'provider', iteration: null }, { head: 'b'.repeat(40), label: 'Iteration 2', iteration: 2 }, { head: 'branch-name', label: 'invalid', iteration: null }]); + expect(choices).toHaveLength(2); expect(choices[0].label).toContain('My reviewed head'); expect(choices[0].head).toBe(head); + }); + it('parses multiple, empty and blank-line suggestions without guessing offsets or nested fences', () => { + expect(suggestionBlocks('```suggestion\nx\ny\n```\n```suggestion\n```\n```suggestion\n\n```')).toEqual(['x\ny\n', '', '\n']); + expect(suggestionBlocks('```text\n```suggestion\nx\n```')).toEqual([]); + expect(suggestionBlocks('```suggestion:-1+2\nx\n```')).toEqual([]); + expect(suggestionBlocks('```suggestion\nunclosed')).toEqual([]); + }); + it('pins a candidate to its original body, stable IDs and source head', () => { + const t = thread(); + const feedback: PullRequestFeedback = { source_commit: head, threads: [t, t, { ...thread('resolved'), is_resolved: true }] }; + const choices = feedbackSuggestions(feedback); + expect(choices).toHaveLength(1); expect(choices[0].label).toContain('outdated'); + expect(choices[0].request).toEqual({ thread_id: 'T', comment_id: 'C', suggestion_index: 0, expected_head: head, expected_body: t.comments[0].body }); + }); + it('exports 101 replies once with source, provider, old-side/iteration and file-level context', () => { + const t = thread(); t.side = 'deletions'; + t.comments = Array.from({ length: 101 }, (_, id) => ({ ...t.comments[0], id: String(id), body: `Feedback ${id}` })); + t.comments.push(t.comments[100]); + const file = { ...thread('file'), start_line: 0, end_line: 0 }; + file.comments[0].body = '# Embedded heading\n'; + const feedback = { source_commit: head, threads: [t, t, file, { ...thread('resolved'), is_resolved: true }] }; + const output = exportHostedFeedback({ id: 42, title: 'Review', url: 'https://example.test/pr/42' }, feedback); + expect(output.match(/> Feedback \d+/g)).toHaveLength(101); + expect(output).toContain(`Source commit: ${head}`); expect(output).toContain('https://example.test/pr/42'); + expect(output).toContain('old lines 2–3'); expect(output).toContain('Outdated · Azure iteration 2'); + expect(output).toContain('src/a.ts · file feedback'); expect(output).toContain('> # Embedded heading'); expect(output).not.toContain('Thread ID: resolved'); + }); +}); diff --git a/ui/src/lib/hostedReview.ts b/ui/src/lib/hostedReview.ts new file mode 100644 index 00000000..b0fc2f80 --- /dev/null +++ b/ui/src/lib/hostedReview.ts @@ -0,0 +1,62 @@ +import type { PullRequest, PullRequestBoundary, PullRequestFeedback, PullRequestReviewThread, PullRequestSuggestionRequest } from './types'; + +/** Exact standard suggestion fences; offset forms have no guessed coordinates. */ +export function suggestionBlocks(body: string): string[] { + const result: string[] = []; + let fence: { width: number; wanted: boolean; lines: string[] } | null = null; + for (const line of body.split(/\r?\n/)) { + const trimmed = line.trim(); + if (fence) { + if (/^`+$/.test(trimmed) && trimmed.length >= fence.width) { + if (fence.wanted) result.push(fence.lines.length ? fence.lines.join('\n') + '\n' : ''); + fence = null; + } else fence.lines.push(line); + } else { + const width = trimmed.match(/^`{3,}/)?.[0].length; + if (width) fence = { width, wanted: trimmed.slice(width).trim() === 'suggestion', lines: [] }; + } + } + return result; +} + +export function reviewBoundaries(saved: { head: string; reviewedAt: string } | null, provider: PullRequestBoundary[]): PullRequestBoundary[] { + const rows = saved ? [{ head: saved.head, label: `My reviewed head · ${saved.reviewedAt}`, iteration: null }, ...provider] : provider; + const seen = new Set(); + return rows.filter(row => /^[a-f0-9]{40}$/i.test(row.head) && !seen.has(row.head) && !!seen.add(row.head)); +} + +export function feedbackLocation(thread: PullRequestReviewThread): string { + const line = thread.end_line > 0 ? `${thread.side === 'additions' ? 'new' : 'old'} lines ${thread.start_line}–${thread.end_line}` : 'file feedback'; + return thread.path ? `${thread.path} · ${line}` : 'Pull request feedback'; +} + +export function feedbackSuggestions(feedback: PullRequestFeedback): { key: string; label: string; request: PullRequestSuggestionRequest }[] { + const seen = new Set(); + return feedback.threads.filter(t => !t.is_resolved).flatMap(thread => thread.comments.flatMap(comment => + suggestionBlocks(comment.body).map((_, index) => ({ + key: JSON.stringify([thread.id, comment.id, index]), + label: `${feedbackLocation(thread)} · ${comment.author} · suggestion ${index + 1}${thread.is_outdated ? ' · outdated' : ''}`, + request: { thread_id: thread.id, comment_id: comment.id, suggestion_index: index, expected_head: feedback.source_commit, expected_body: comment.body }, + })), + )).filter(row => !seen.has(row.key) && !!seen.add(row.key)); +} + +/** Literal quoted feedback keeps embedded headings/HTML separate from our context. */ +export function exportHostedFeedback(pr: Pick, feedback: PullRequestFeedback): string { + const literal = (text: string) => text.split(/\r?\n/).map(line => `> ${line}`).join('\n'); + const sections = [`# Unresolved pull request feedback`, literal(`#${pr.id} ${pr.title}\n${pr.url}`), `Source commit: ${feedback.source_commit}`, 'Snapshot of unresolved discussion. Old-side, outdated and prior-iteration coordinates are context, not instructions to edit current lines.']; + const threads = new Set(); + for (const thread of feedback.threads) { + if (thread.is_resolved || threads.has(thread.id)) continue; + threads.add(thread.id); + sections.push(`## Thread ${threads.size}`, literal(`${feedbackLocation(thread)}\nThread ID: ${thread.id}\n${thread.is_outdated ? 'Outdated' : 'Provider has not marked outdated'}${thread.iteration_id != null ? ` · Azure iteration ${thread.iteration_id}` : ''}`)); + const comments = new Set(); + for (const comment of thread.comments) { + if (comments.has(comment.id) || comment.is_system) continue; + comments.add(comment.id); + sections.push(literal(`${comment.author} · ${comment.created_at}\nComment ID: ${comment.id}\n${comment.url}\n\n${comment.body}`)); + } + } + if (!threads.size) sections.push('No unresolved feedback.'); + return sections.join('\n\n') + '\n'; +} diff --git a/ui/src/lib/hostingCapabilities.test.ts b/ui/src/lib/hostingCapabilities.test.ts new file mode 100644 index 00000000..2b0250af --- /dev/null +++ b/ui/src/lib/hostingCapabilities.test.ts @@ -0,0 +1,37 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterAll, describe, expect, it } from 'vitest'; +import type { PullRequest } from './types'; + +const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); +if (typeof navigator === 'undefined') Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'vitest', platform: '', maxTouchPoints: 0 } }); +afterAll(() => { + if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); + else Reflect.deleteProperty(globalThis, 'navigator'); +}); +const { PullRequestMergeControl } = await import('../views/PullRequestMergeControl'); +const { providerName } = await import('./pullRequests'); +const pr = { id: 7, state: 'open', is_draft: false, can_mark_ready: false, source_commit: 'a'.repeat(40) } as PullRequest; +const caps = { can_comment: true, can_review: true, can_request_changes: false, can_close: true, can_reopen: false, merge_strategies: [] }; + +describe('hosted provider capabilities', () => { + it('keeps Bitbucket merge on the provider when atomic head protection is unavailable', () => { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider: 'bitbucket', pr: { ...pr, capabilities: caps }, disabledReason: '', onMerged: () => {}, onToast: () => {} })); + expect(html).toContain('Merge on Bitbucket Cloud'); + expect(html).not.toContain(' { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider: 'git_lab', pr: { ...pr, capabilities: { ...caps, merge_strategies: ['merge_commit'] } }, disabledReason: '', onMerged: () => {}, onToast: () => {} })); + expect(html).toContain('Merge with project settings'); + expect(html).not.toContain('Create a merge commit'); + }); + it('retains existing provider behavior when optional capabilities are absent', () => { + for (const provider of ['git_hub', 'azure_dev_ops'] as const) { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider, pr, disabledReason: '', onMerged: () => {}, onToast: () => {} })); + expect(html).toContain('Merge pull request'); + expect(html).toContain('Choose merge strategy'); + } + expect(providerName('git_lab')).toBe('GitLab'); + expect(providerName('bitbucket')).toBe('Bitbucket Cloud'); + }); +}); diff --git a/ui/src/lib/interchange.test.ts b/ui/src/lib/interchange.test.ts new file mode 100644 index 00000000..a3a11001 --- /dev/null +++ b/ui/src/lib/interchange.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +const invoke = vi.hoisted(() => vi.fn()); +vi.mock('@tauri-apps/api/core', () => ({ invoke, Channel: class {} })); +import { tauri } from './tauri'; + +beforeEach(() => invoke.mockReset()); + +it('carries the exact reviewed patch target and token over IPC without rewriting paths', async () => { + const preview = { token: 'bytes-and-index', paths: ['space name.txt'], valid: true, messages: [], validation: '' }; + invoke.mockResolvedValueOnce(preview).mockResolvedValueOnce({ success: true, paused: false, output: '' }); + const result = await tauri.repoPatchPreview('C:/repo with spaces', 'C:/patch files/a.patch', 'index'); + await tauri.repoPatchImport('C:/repo with spaces', 'C:/patch files/a.patch', 'index', result.token); + expect(invoke).toHaveBeenLastCalledWith('repo_patch_import', { path: 'C:/repo with spaces', source: 'C:/patch files/a.patch', target: 'index', token: 'bytes-and-index' }); +}); + +it('keeps mailbox recovery distinct from rebase and propagates a stale-state rejection', async () => { + invoke.mockRejectedValueOnce({ message: 'mailbox state changed; refresh before continuing' }); + await expect(tauri.repoMailboxAction('repo', 'skip', 'reviewed')).rejects.toMatchObject({ message: expect.stringContaining('changed') }); + expect(invoke).toHaveBeenCalledWith('repo_mailbox_action', { path: 'repo', action: 'skip', token: 'reviewed' }); +}); + +it('imports the chosen advertised bundle ref into an explicit new branch', async () => { + await tauri.repoBundleImport('repo', '/tmp/a.bundle', 'file stamp', 'refs/tags/v1', 'import/v1'); + expect(invoke).toHaveBeenCalledWith('repo_bundle_import', { path: 'repo', source: '/tmp/a.bundle', token: 'file stamp', sourceRef: 'refs/tags/v1', branch: 'import/v1' }); +}); diff --git a/ui/src/lib/interchange.ts b/ui/src/lib/interchange.ts new file mode 100644 index 00000000..509680a6 --- /dev/null +++ b/ui/src/lib/interchange.ts @@ -0,0 +1,5 @@ +export type PatchTarget = 'worktree' | 'index' | 'both' | 'mailbox'; +export interface PatchPreview { token: string; paths: string[]; messages: string[]; valid: boolean; validation: string } +export interface MailboxState { token: string; current: string; total: string; author: string; conflicts: boolean } +export interface InterchangeOutcome { success: boolean; paused: boolean; output: string } +export interface BundlePreview { token: string; refs: Array<{ oid: string; name: string }>; prerequisites: string[]; valid: boolean; validation: string } diff --git a/ui/src/lib/lfs.ts b/ui/src/lib/lfs.ts new file mode 100644 index 00000000..d4599837 --- /dev/null +++ b/ui/src/lib/lfs.ts @@ -0,0 +1,15 @@ +export const LFS_ACTIONS = [ + ['environment', 'Installation and configuration'], + ['install', 'Set up this repository'], + ['patterns', 'Tracked patterns'], + ['track', 'Track a pattern'], + ['untrack', 'Stop tracking a pattern'], + ['status', 'Object and transfer status'], + ['objects', 'List objects and sizes'], + ['fetch', 'Download objects'], + ['pull', 'Download and check out objects'], + ['push', 'Upload objects'], + ['locks', 'List locks'], + ['lock', 'Lock a file'], + ['unlock', 'Unlock by ID'], +] as const; diff --git a/ui/src/lib/menu.ts b/ui/src/lib/menu.ts index a1b70fe5..0b0f55d6 100644 --- a/ui/src/lib/menu.ts +++ b/ui/src/lib/menu.ts @@ -48,6 +48,10 @@ export interface MenuHandlers { push(): void; openInEditor(): void; openInTerminal(): void; + openInterchange(): void; + openBisect(): void; + openAdvancedRefs(): void; + openGitflow(): void; } let preemptsKeydown = false; @@ -195,6 +199,10 @@ export async function installAppMenu( const repoMenu = await Submenu.new({ text: 'Repository', items: [ + await item({ id: 'git-interchange', text: 'Patches, Mailboxes & Bundles…', enabled: hasRepo, action: () => handlers().openInterchange() }), + await item({ id: 'git-advanced-refs', text: 'Git Notes, Replacements & Tag Editing…', enabled: hasRepo, action: () => handlers().openAdvancedRefs() }), + await item({ id: 'gitflow', text: 'Git-flow Workflows…', enabled: hasRepo, action: () => handlers().openGitflow() }), + await item({ id: 'git-bisect', text: 'Guided Bisect…', enabled: hasRepo, action: () => handlers().openBisect() }), await item({ id: 'sync', text: 'Sync (Fetch + Pull + Push)', diff --git a/ui/src/lib/pullRequestCompletion.test.ts b/ui/src/lib/pullRequestCompletion.test.ts new file mode 100644 index 00000000..b3611144 --- /dev/null +++ b/ui/src/lib/pullRequestCompletion.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest'; +import { completionAction, completionLabel } from './pullRequestCompletion'; +import type { PullRequestCompletion } from './types'; + +it('distinguishes a queued request from waiting policies and a completed merge', () => { + const state = { kind: 'github_queue', status: 'queued', position: 7 } as PullRequestCompletion; + expect(completionLabel(state)).toBe('In GitHub merge queue · position 7'); + expect(completionAction(state, false)).toBe('Leave merge queue'); + expect(completionLabel({ ...state, status: 'merged' })).toBe('Merged'); + expect(completionLabel({ ...state, kind: 'azure_auto_complete', status: 'waiting_for_policies', position: null })).toBe('Azure auto-complete enabled · waiting for policies'); + expect(completionAction({ ...state, kind: 'azure_auto_complete' }, false)).toBe('Cancel auto-complete'); +}); diff --git a/ui/src/lib/pullRequestCompletion.ts b/ui/src/lib/pullRequestCompletion.ts new file mode 100644 index 00000000..ae579a4f --- /dev/null +++ b/ui/src/lib/pullRequestCompletion.ts @@ -0,0 +1,18 @@ +import type { PullRequestCompletion } from './types'; + +export function completionLabel(state: PullRequestCompletion): string { + if (state.status === 'merged') return 'Merged'; + if (state.status === 'closed') return 'Closed'; + if (state.status === 'queued') return `In GitHub merge queue${state.position != null ? ` · position ${state.position}` : ''}`; + if (state.status === 'waiting_for_policies') return state.kind === 'azure_auto_complete' + ? 'Azure auto-complete enabled · waiting for policies' + : 'GitHub auto-merge enabled · waiting for policies'; + return state.kind === 'github_queue' ? 'GitHub merge queue required' + : state.kind === 'azure_auto_complete' ? 'Azure auto-complete off' : 'GitHub auto-merge off'; +} + +export function completionAction(state: PullRequestCompletion, enable: boolean): string { + if (state.kind === 'github_queue') return enable ? 'Join merge queue' : state.status === 'queued' ? 'Leave merge queue' : 'Cancel auto-merge'; + if (state.kind === 'azure_auto_complete') return enable ? 'Enable auto-complete' : 'Cancel auto-complete'; + return enable ? 'Enable auto-merge' : 'Cancel auto-merge'; +} diff --git a/ui/src/lib/pullRequestPages.test.ts b/ui/src/lib/pullRequestPages.test.ts new file mode 100644 index 00000000..d227af4e --- /dev/null +++ b/ui/src/lib/pullRequestPages.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { appendPullRequestPage, incompleteLabel, uniqueBy } from './pullRequestPages'; +import type { PullRequest, PullRequestDataPage, PullRequestPageCursor } from './types'; + +const cursor = (kind: PullRequestPageCursor['kind'], after: string | null): PullRequestPageCursor => + ({ kind, cursor: after, thread_id: null, total: 101, error: null }); +const pr = (kind: PullRequestPageCursor['kind']): PullRequest => ({ + source_commit: 'a'.repeat(40), data_pages: [cursor(kind, null)], + comments: [], commits: [], reviews: [], checks: [], review_threads: [], checks_complete: false, +} as unknown as PullRequest); +const page = (request: PullRequestPageCursor): PullRequestDataPage => ({ + request, source_commit: 'a'.repeat(40), pending: [], comments: [], commits: [], reviews: [], checks: [], review_threads: [], +}); + +describe('provider pages', () => { + it.each(['comments', 'commits', 'reviews', 'checks', 'threads'] as const)('loads 101 %s with overlapping pages exactly once', (kind) => { + let current = pr(kind); + const field = kind === 'threads' ? 'review_threads' : kind; + for (const [start, end, after, next] of [[0, 50, null, '50'], [49, 100, '50', '100'], [99, 101, '100', null]] as const) { + const incoming = page(cursor(kind, after)); + incoming.pending = next ? [cursor(kind, next)] : []; + Object.assign(incoming, { [field]: Array.from({ length: end - start }, (_, i) => ({ id: `${start + i}`, comments: [] })) }); + current = appendPullRequestPage(current, incoming); + expect(appendPullRequestPage(current, incoming)).toBe(current); + } + expect(current[field]).toHaveLength(101); + expect(incompleteLabel(current)).toBe(''); + expect(current.checks_complete).toBe(true); + }); + it('deduplicates inbox rows without losing existing selection objects', () => { + const rows = Array.from({ length: 100 }, (_, id) => ({ id })); + expect(uniqueBy(rows, [{ id: 99 }, { id: 100 }], (p) => p.id)).toHaveLength(101); + }); + it('rejects an old head and preserves prior data on invalid cursor', () => { + const current = pr('reviews'); + const incoming = page(cursor('reviews', null)); + incoming.source_commit = 'b'.repeat(40); + expect(appendPullRequestPage(current, incoming)).toBe(current); + incoming.source_commit = current.source_commit; + incoming.request.cursor = 'unexpected'; + expect(appendPullRequestPage(current, incoming)).toBe(current); + expect(incompleteLabel(current)).toContain('Counts show loaded items'); + }); + it('loads 101 replies into the original thread and timeline with file coordinates intact', () => { + let current = pr('replies'); + current.data_pages![0].thread_id = 'thread'; + current.review_threads = [{ id: 'thread', path: 'src/a.ts', comments: [] }] as unknown as PullRequest['review_threads']; + const incoming = page(current.data_pages![0]); + incoming.comments = Array.from({ length: 101 }, (_, i) => ({ id: `${i}`, path: null })) as PullRequest['comments']; + current = appendPullRequestPage(current, incoming); + expect(current.review_threads[0].comments).toHaveLength(101); + expect(current.comments).toHaveLength(101); + expect(current.comments[100].path).toBe('src/a.ts'); + }); +}); diff --git a/ui/src/lib/pullRequestPages.ts b/ui/src/lib/pullRequestPages.ts new file mode 100644 index 00000000..357b0b42 --- /dev/null +++ b/ui/src/lib/pullRequestPages.ts @@ -0,0 +1,40 @@ +import type { PullRequest, PullRequestDataPage, PullRequestPageCursor } from './types'; + +export const pageKey = (page: PullRequestPageCursor) => `${page.kind}:${page.thread_id ?? ''}`; + +export function uniqueBy(old: readonly T[], incoming: readonly T[], key: (item: T) => string | number): T[] { + const result = new Map(old.map((item) => [key(item), item])); + for (const item of incoming) result.set(key(item), item); + return [...result.values()]; +} + +/** Reject both stale heads and out-of-order/replayed cursor responses. */ +export function appendPullRequestPage(pr: PullRequest, page: PullRequestDataPage): PullRequest { + const pending = pr.data_pages ?? []; + if (pr.source_commit !== page.source_commit || !pending.some((p) => + pageKey(p) === pageKey(page.request) && p.cursor === page.request.cursor)) return pr; + const data_pages = uniqueBy(pending.filter((p) => pageKey(p) !== pageKey(page.request)), page.pending, pageKey); + let review_threads = uniqueBy(pr.review_threads, page.review_threads, (t) => t.id); + if (page.request.kind === 'replies') { + review_threads = review_threads.map((thread) => thread.id === page.request.thread_id ? { + ...thread, comments: uniqueBy(thread.comments, page.comments.map((c) => ({ ...c, path: thread.path })), (c) => c.id), + } : thread); + } + const comments = uniqueBy(pr.comments, [ + ...(page.request.kind === 'comments' ? page.comments : []), + ...review_threads.flatMap((thread) => thread.comments), + ], (c) => c.id); + const commits = uniqueBy(pr.commits, page.commits, (c) => c.id); + return { + ...pr, data_pages, review_threads, comments, commits, + comment_count: comments.length, commit_count: commits.length, + reviews: uniqueBy(pr.reviews, page.reviews, (r) => r.id), + checks: uniqueBy(pr.checks, page.checks, (c) => c.id || c.name), + checks_complete: !data_pages.some((p) => p.kind === 'checks'), + }; +} + +export function incompleteLabel(pr: PullRequest): string { + const kinds = [...new Set((pr.data_pages ?? []).map((p) => p.kind))]; + return kinds.length ? `Partial data: ${kinds.join(', ')}. Counts show loaded items.` : ''; +} diff --git a/ui/src/lib/pullRequests.ts b/ui/src/lib/pullRequests.ts index 11cd68ea..7a51dd7b 100644 --- a/ui/src/lib/pullRequests.ts +++ b/ui/src/lib/pullRequests.ts @@ -398,3 +398,7 @@ export function pullRequestForBranch( return source === current && (state === 'open' || state === 'active'); }) ?? null; } + +export function providerName(provider: PullRequestProvider): string { + return { git_hub: 'GitHub', azure_dev_ops: 'Azure DevOps', git_lab: 'GitLab', bitbucket: 'Bitbucket Cloud' }[provider]; +} diff --git a/ui/src/lib/submodules.ts b/ui/src/lib/submodules.ts new file mode 100644 index 00000000..013caa6f --- /dev/null +++ b/ui/src/lib/submodules.ts @@ -0,0 +1,11 @@ +export const SUBMODULE_ACTIONS = [ + ['inspect', 'Inspect working-tree status'], + ['add', 'Add submodule'], + ['update', 'Initialize / update submodule'], + ['update-all', 'Initialize / update all submodules'], + ['sync', 'Sync configured URLs'], + ['set-url', 'Change submodule URL'], + ['deinit', 'Deinitialize submodule'], + ['remove', 'Remove submodule'], +] as const; +export type SubmoduleDialogAction = typeof SUBMODULE_ACTIONS[number][0]; diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index e7356543..befddeee 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -1,4 +1,12 @@ +import type { RemoteHostingProvider, PublishAccount, PublishRequest, PublishState } from './types'; + +import type { FlowAction, FlowConfig, FlowKind, FlowOutcome, FlowPlan, FlowState, FlowTool } from './gitflow'; import { Channel, invoke } from '@tauri-apps/api/core'; +import type { AdvancedRefs, GitNote, ReplaceReview, TagEditReview, TagEditKind, PublishedTag } from './advancedRefs'; +import type { BisectAction, BisectState, BisectOutcome } from './bisect'; +import type { PatchTarget, PatchPreview, MailboxState, InterchangeOutcome, BundlePreview } from './interchange'; + +import type { UserAction, ActionContext, ActionPreview, ActionOutcome } from './userActions'; import type { AiProvider, @@ -12,6 +20,10 @@ import type { BranchPushRequest, CheckoutOutcome, CloneOutcome, + CloneOptions, + CloneScope, + SparseCheckout, + HistoryExpansion, Commit, CommitSignature, CommitMessageSuggestion, @@ -25,6 +37,11 @@ import type { FileHistoryEntry, FileStatus, GlobalIdentity, + RepositoryIdentity, + SigningMode, + SigningScope, + SigningSettings, + TagVerification, HostingConnectionStatus, HeroiAgentEvent, HeroiAgentOutcome, @@ -35,11 +52,16 @@ import type { InitOutcome, MaintenanceOutcome, MaintenanceTask, + LfsAction, + SubmoduleAction, + SubmodulePage, MergeMode, NetworkOutcome, Progress, PullMode, PullRequest, + PullRequestPageCursor, + PullRequestDataPage, PullRequestActivitySnapshot, PullRequestBranchMatch, PullRequestCheckoutPreparation, @@ -49,6 +71,8 @@ import type { PullRequestLifecycleAction, PullRequestMergeStrategy, PullRequestPendingComment, + PullRequestBoundary, PullRequestComparison, PullRequestFeedback, + PullRequestSuggestionRequest, PullRequestSuggestionPreview, PullRequestReviewEvent, PullRequestReviewThreadUpdate, PullRequestSuggestion, @@ -115,6 +139,40 @@ export function errMessage(e: unknown): string { * frontend never calls `invoke` with a string literal. */ export const tauri = { + repoGitflowDetect: () => invoke('repo_gitflow_detect'), + repoGitflowState: (path: string) => invoke('repo_gitflow_state', { path }), + repoGitflowConfigure: (path: string, config: FlowConfig, enabled: boolean, token: string) => invoke('repo_gitflow_configure', { path, config, enabled, token }), + repoGitflowPlan: (path: string, kind: FlowKind, action: FlowAction, name: string) => invoke('repo_gitflow_plan', { path, kind, action, name }), + repoGitflowRun: (path: string, plan: FlowPlan, onProgress: (output: string) => void) => { + const onEvent = new Channel(); onEvent.onmessage = onProgress; + return invoke('repo_gitflow_run', { path, plan, onEvent }); + }, + repoAdvancedRefs: (path: string, notesRef: string) => invoke('repo_advanced_refs', { path, notesRef }), + repoGitNote: (path: string, notesRef: string, revision: string) => invoke('repo_git_note', { path, notesRef, revision }), + repoGitNoteWrite: (path: string, notesRef: string, object: string, expected: string | null, message: string | null) => invoke('repo_git_note_write', { path, notesRef, object, expected, message }), + repoReplaceReview: (path: string, original: string, replacement: string) => invoke('repo_replace_review', { path, original, replacement }), + repoReplaceWrite: (path: string, original: string, replacement: string | null, expected: string | null) => invoke('repo_replace_write', { path, original, replacement, expected }), + repoTagEditReview: (path: string, name: string, target: string) => invoke('repo_tag_edit_review', { path, name, target }), + repoTagEdit: (path: string, name: string, target: string, expected: string, kind: TagEditKind, message: string | null) => invoke('repo_tag_edit', { path, name, target, expected, kind, message }), + repoTagPublished: (path: string, remote: string, name: string) => invoke('repo_tag_published', { path, remote, name }), + repoBisectState: (path: string) => invoke('repo_bisect_state', { path }), + repoBisectStart: (path: string, good: string, bad: string, token: string) => invoke('repo_bisect_start', { path, good, bad, token }), + repoBisectAction: (path: string, action: BisectAction, token: string) => invoke('repo_bisect_action', { path, action, token }), + repoPatchPreview: (path: string, source: string, target: PatchTarget) => invoke('repo_patch_preview', { path, source, target }), + repoPatchImport: (path: string, source: string, target: PatchTarget, token: string) => invoke('repo_patch_import', { path, source, target, token }), + repoMailboxState: (path: string) => invoke('repo_mailbox_state', { path }), + repoMailboxAction: (path: string, action: 'continue' | 'skip' | 'abort', token: string) => invoke('repo_mailbox_action', { path, action, token }), + repoBundlePreview: (path: string, source: string) => invoke('repo_bundle_preview', { path, source }), + repoBundleImport: (path: string, source: string, token: string, sourceRef: string, branch: string) => invoke('repo_bundle_import', { path, source, token, sourceRef, branch }), + repoBundleExport: (path: string, destination: string, refname: string, prerequisite: string | null) => invoke('repo_bundle_export', { path, destination, refname, prerequisite }), + + repoUserActionPreview: (action: UserAction, context: ActionContext) => + invoke('repo_user_action_preview', { action, context }), + repoUserActionRun: (action: UserAction, context: ActionContext, preview: ActionPreview, opId: string, onStarted: () => void) => { + const channel = new Channel(); + channel.onmessage = onStarted; + return invoke('repo_user_action_run', { action, context, preview, opId, onStarted: channel }); + }, microsoftStoreUpdateAvailable: () => invoke('microsoft_store_update_available'), microsoftStoreOpenProduct: () => @@ -126,6 +184,13 @@ export const tauri = { createInitialCommit: boolean, ) => invoke('repo_init', { path, initialBranch, gitignore, createInitialCommit }), azdoHelperStatus: () => invoke('azdo_helper_status'), + repoHostingProviders: (path: string) => invoke('repo_hosting_providers', { path }), + repoSetHostingProvider: (path: string, remote: string, provider: string) => invoke('repo_set_hosting_provider', { path, remote, provider }), + hostedPublishAccounts: (path: string, provider: string, host: string) => invoke('hosted_publish_accounts', { path, provider, host }), + hostedPublishState: (path: string) => invoke('hosted_publish_state', { path }), + hostedPublishPreview: (path: string, request: PublishRequest) => invoke('hosted_publish_preview', { path, request }), + hostedPublishAdvance: (path: string, id: string, action: string) => invoke('hosted_publish_advance', { path, id, action }), + hostedPublishForget: (path: string) => invoke('hosted_publish_forget', { path }), hostingConnectionStatus: () => invoke('hosting_connection_status'), azdoHelperEnable: () => invoke('azdo_helper_enable'), azdoHelperDisable: () => invoke('azdo_helper_disable'), @@ -195,6 +260,11 @@ export const tauri = { repoCommitExportPatch: (path: string, oids: string[], destination: string) => invoke('repo_commit_export_patch', { path, oids, destination }), repoRefs: (path: string) => invoke('repo_refs', { path }), + repoPullRequestInboxPage: (path: string, cursor: string | null, requestId: string) => + invoke('repo_pull_request_inbox_page', { path, cursor, requestId }), + repoPullRequestDataPage: (path: string, id: number, expectedHead: string, request: PullRequestPageCursor, requestId: string) => + invoke('repo_pull_request_data_page', { path, id, expectedHead, request, requestId }), + repoPullRequestCancelRead: (requestId: string) => invoke('repo_pull_request_cancel_read', { requestId }), repoPullRequests: (path: string) => invoke('repo_pull_requests', { path }), repoPullRequestForBranch: (path: string, branch: string) => invoke('repo_pull_request_for_branch', { path, branch }), @@ -281,6 +351,18 @@ export const tauri = { invoke('repo_pull_request_thread_resolve', { path, threadId, resolved, }), + repoPullRequestBoundaries: (path: string, id: number, expectedHead: string, requestId: string) => + invoke('repo_pull_request_boundaries', { path, id, expectedHead, requestId }), + repoPullRequestCompareReview: (path: string, id: number, from: string, expectedHead: string) => + invoke('repo_pull_request_compare_review', { path, id, from, expectedHead }), + repoPullRequestFeedback: (path: string, id: number, expectedHead: string, requestId: string) => + invoke('repo_pull_request_feedback', { path, id, expectedHead, requestId }), + repoPullRequestSuggestionPreview: (path: string, id: number, request: PullRequestSuggestionRequest, requestId: string) => + invoke('repo_pull_request_suggestion_preview', { path, id, request, requestId }), + repoPullRequestSuggestionApply: (path: string, id: number, request: PullRequestSuggestionRequest, expectedPreview: PullRequestSuggestionPreview, requestId: string) => + invoke('repo_pull_request_suggestion_apply', { path, id, request, expectedPreview, requestId }), + repoPullRequestCompletion: (path: string, id: number, enable: boolean, strategy: PullRequestMergeStrategy, expectedHead: string) => + invoke('repo_pull_request_completion', { path, id, enable, strategy, expectedHead }), repoPullRequestMerge: ( path: string, id: number, @@ -360,8 +442,8 @@ export const tauri = { patch: string, target: 'index' | 'index_reverse' | 'workdir_reverse' | 'workdir', ) => invoke('repo_apply_patch', { path, patch, target }), - repoCommit: (path: string, subject: string, body: string | null, amend: boolean) => - invoke('repo_commit', { path, subject, body, amend }), + repoCommit: (path: string, subject: string, body: string | null, amend: boolean, signing: SigningMode = 'inherit') => + invoke('repo_commit', { path, subject, body, amend, signing }), repoFetch: ( path: string, remote: string | null, @@ -440,8 +522,15 @@ export const tauri = { opId, onEvent: progressChannel(onProgress), }), - repoClone: (url: string, dest: string, onProgress?: (p: Progress) => void, opId?: string) => - invoke('repo_clone', { url, dest, opId, onEvent: progressChannel(onProgress) }), + repoClone: (url: string, dest: string, onProgress?: (p: Progress) => void, opId?: string, options?: CloneOptions) => + invoke('repo_clone', { url, dest, options, opId, onEvent: progressChannel(onProgress) }), + repoCloneScope: (path: string) => invoke('repo_clone_scope', { path }), + repoSparseCheckout: (path: string) => invoke('repo_sparse_checkout', { path }), + repoSetSparseCheckout: (path: string, directories: string[], sparseIndex: boolean) => + invoke('repo_set_sparse_checkout', { path, directories, sparseIndex }), + repoDisableSparseCheckout: (path: string) => invoke('repo_disable_sparse_checkout', { path }), + repoExpandHistory: (path: string, remote: string, expansion: HistoryExpansion, onProgress?: (p: Progress) => void, opId?: string) => + invoke('repo_expand_history', { path, remote, expansion, opId, onEvent: progressChannel(onProgress) }), repoCheckout: (path: string, branch: string) => invoke('repo_checkout', { path, branch }), repoCheckoutCommit: (path: string, rev: string) => @@ -453,12 +542,18 @@ export const tauri = { repoTreeAt: (path: string, rev: string) => invoke('repo_tree_at', { path, rev }), repoSubmodules: (path: string) => invoke('repo_submodules', { path }), + repoSubmoduleChildren: (path: string, parent: string, offset: number) => invoke('repo_submodule_children', { path, parent, offset }), + repoSubmoduleAction: (path: string, action: SubmoduleAction, opId: string, onProgress?: (p: Progress) => void) => + invoke('repo_submodule_action', { path, action, opId, onEvent: progressChannel(onProgress) }), + repoLfsAction: (path: string, action: LfsAction, opId: string, onProgress?: (p: Progress) => void) => + invoke('repo_lfs_action', { path, action, opId, onEvent: progressChannel(onProgress) }), repoSubmoduleUpdate: ( path: string, paths: string[], init: boolean, recursive: boolean, onProgress?: (p: Progress) => void, + opId?: string, ) => invoke('repo_submodule_update', { path, @@ -466,6 +561,7 @@ export const tauri = { init, recursive, onEvent: progressChannel(onProgress), + opId, }), repoWorktrees: (path: string) => invoke('repo_worktrees', { path }), // `startPoint` (branch/tag/commit; null = HEAD) and `track` (set upstream to @@ -575,7 +671,8 @@ export const tauri = { target: string | null, message: string | null, force: boolean, - ) => invoke('repo_tag_create', { path, name, target, message, force }), + signing: SigningMode = 'inherit', + ) => invoke('repo_tag_create', { path, name, target, message, force, signing }), repoTagDelete: (path: string, name: string) => invoke('repo_tag_delete', { path, name }), repoTagPush: ( @@ -640,6 +737,13 @@ export const tauri = { invoke('repo_open_in_editor', { path, file, line, template }), repoOpenInTerminal: (path: string, template: string) => invoke('repo_open_in_terminal', { path, template }), + repoTagVerify: (path: string, name: string) => invoke('repo_tag_verify', { path, name }), + repoSigningSettings: (path: string) => invoke('repo_signing_settings', { path }), + repoSetSigningConfig: (path: string, scope: SigningScope, key: string, value: string | null) => + invoke('repo_set_signing_config', { path, scope, key, value }), + repoIdentity: (path: string) => invoke('repo_identity', { path }), + repoSetIdentity: (path: string, field: 'name' | 'email', value: string | null) => + invoke('repo_set_identity', { path, field, value }), gitGlobalIdentity: () => invoke('git_global_identity'), gitSetGlobalIdentity: (name: string, email: string) => invoke('git_set_global_identity', { name, email }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c27be6a0..4e5412ce 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -17,7 +17,7 @@ export interface RepoMeta { * Multi-step history op paused mid-flight, or `null` in a normal state. * Drives the in-progress banner + Abort affordance. */ - operation: 'rebase' | 'cherry-pick' | 'revert' | 'merge' | null; + operation: 'rebase' | 'cherry-pick' | 'revert' | 'merge' | 'mailbox' | 'bisect' | null; /** * The shared git dir (`commondir`), identical for every worktree of the same * repository. The tab strip groups worktree tabs on this value. @@ -162,6 +162,7 @@ export interface ReviewNote { export interface CommitOutcome { oid: string; amended: boolean; + output: string; } export interface UpstreamRef { @@ -255,7 +256,7 @@ export interface HostingConnectionStatus { azure_dev_ops: ProviderConnectionStatus; } -export type PullRequestProvider = 'git_hub' | 'azure_dev_ops'; +export type PullRequestProvider = 'git_hub' | 'azure_dev_ops' | 'git_lab' | 'bitbucket'; export type PullRequestMergeStrategy = 'merge_commit' | 'squash' | 'rebase'; export type PullRequestLifecycleAction = 'close' | 'reopen'; export type PullRequestReviewEvent = 'comment' | 'approve' | 'request_changes'; @@ -275,6 +276,7 @@ export interface PullRequestReviewer { } export interface PullRequestCheck { + id?: string; name: string; status: string; } @@ -301,6 +303,8 @@ export interface PullRequestComment { } export interface PullRequestReviewThread { + suggestion_range_valid?: boolean; + iteration_id?: number | null; id: string; path: string; start_line: number; @@ -324,6 +328,7 @@ export interface PullRequestReviewThreadUpdate { } export interface PullRequestReview { + source_commit?: string | null; id: string; author: string; avatar_url: string | null; @@ -337,7 +342,22 @@ export interface PullRequestReview { can_dismiss: boolean; } +export interface PullRequestCompletion { + kind: 'github_queue' | 'github_auto_merge' | 'azure_auto_complete'; + status: 'disabled' | 'queued' | 'waiting_for_policies' | 'merged' | 'closed'; + source_commit: string; + position: number | null; + can_enable: boolean; + can_cancel: boolean; + blockers: string[]; + strategies: PullRequestMergeStrategy[]; +} + export interface PullRequest { + completion?: PullRequestCompletion | null; + data_pages?: PullRequestPageCursor[]; + + capabilities?: { can_comment: boolean; can_review: boolean; can_request_changes: boolean; can_close: boolean; can_reopen: boolean; merge_strategies: PullRequestMergeStrategy[] }; id: number; title: string; state: string; @@ -373,6 +393,8 @@ export interface PullRequest { } export interface PullRequestList { + next_cursor?: string | null; + total_count?: number | null; repository: PullRequestRepository; pull_requests: PullRequest[]; } @@ -539,6 +561,30 @@ export interface CloneOutcome { output: string; } +export interface CloneOptions { + branch: string | null; + depth: number | null; + single_branch: boolean; + filter: 'blob-none' | null; + recurse_submodules: boolean; +} + +export interface CloneScope { + shallow: boolean; + remotes: { name: string; filter: string | null; fetch_refspecs: string[] }[]; +} + +export type HistoryExpansion = { kind: 'deepen'; commits: number } | { kind: 'unshallow' }; + +export interface SparseCheckout { + enabled: boolean; + cone: boolean; + sparse_index: boolean; + directories: string[]; + available: string[]; + patterns: string; +} + /** One file in the working-tree view (Files sidebar tab). */ export interface WorkTreeEntry { path: string; @@ -546,6 +592,7 @@ export interface WorkTreeEntry { status: StatusKind | null; /** Git-ignored local file; intentionally not represented as a change status. */ ignored: boolean; + excluded?: boolean; } /** Shell used by Work's embedded terminal. Commands are tokenized into argv @@ -587,6 +634,14 @@ export type FilesTreeMutation = FilesTreeMutationChange & { /** A submodule's state relative to the superproject's recorded commit. */ export type SubmoduleState = 'uninitialized' | 'up-to-date' | 'out-of-date' | 'modified'; +export type LfsAction = + | { action: 'environment' | 'install' | 'patterns' | 'status' | 'objects' } + | { action: 'track' | 'untrack'; pattern: string } + | { action: 'fetch' | 'pull' | 'push'; remote: string } + | { action: 'locks'; path: string } + | { action: 'lock'; path: string } + | { action: 'unlock'; id: string }; + export interface Submodule { name: string; /** Path within the superproject working tree (forward-slashed). */ @@ -600,6 +655,16 @@ export interface Submodule { status: SubmoduleState; } +export type SubmoduleAction = + | { action: 'add' | 'set-url'; path: string; url: string } + | { action: 'remove' | 'deinit' | 'inspect'; path: string } + | { action: 'sync' | 'update'; path: string; recursive: boolean }; + +export interface SubmodulePage { + modules: Submodule[]; + next_offset: number | null; +} + /** One entry in the repository's worktree registry (`git worktree list`). */ export interface Worktree { /** Absolute worktree directory, forward-slashed. */ @@ -927,3 +992,77 @@ export type AiGenerationOutcome = coverage: AiInputCoverage; provider: AiProvider; }; + +export interface PullRequestPageCursor { + kind: 'comments' | 'commits' | 'reviews' | 'threads' | 'replies' | 'checks'; + thread_id: string | null; + cursor: string | null; + total: number | null; + error: string | null; +} +export interface PullRequestDataPage { + source_commit: string; + request: PullRequestPageCursor; + pending: PullRequestPageCursor[]; + comments: PullRequestComment[]; + commits: PullRequestCommit[]; + reviews: PullRequestReview[]; + review_threads: PullRequestReviewThread[]; + checks: PullRequestCheck[]; +} + +export interface PullRequestBoundary { head: string; label: string; iteration: number | null } +export interface PullRequestComparison { from: string; to: string; history_rewritten: boolean; diffs: FileDiff[] } +export interface PullRequestFeedback { source_commit: string; threads: PullRequestReviewThread[] } +export interface PullRequestSuggestionRequest { + thread_id: string; comment_id: string; suggestion_index: number; + expected_head: string; expected_body: string; +} +export interface PullRequestSuggestionPreview { + path: string; start_line: number; end_line: number; + before: string; after: string; expected_file: string; +} + +export interface RemoteHostingProvider { remote: string; url: string; provider: string } +export interface PublishDestination { id: string; label: string; kind: string } +export interface PublishAccount { account: string; account_id: string; destinations: PublishDestination[] } +export interface PublishRequest { provider: 'github' | 'gitlab' | 'bitbucket'; host: string; account_id: string; destination: string; name: string; visibility: 'private' | 'public'; remote: string } +export interface PublishState { + id: string; request: PublishRequest; account: string; destination: PublishDestination; + url: string; clone_url: string; branch: string; head: string; + stage: 'review' | 'uncertain' | 'created' | 'remote_ready' | 'pushed'; error: string | null; +} + +export interface ScopedValue { + value: string; + scope: string; + origin: string; +} +export interface EffectiveIdentity { + identity: string | null; + error: string | null; + name_source: ScopedValue; + email_source: ScopedValue; +} +export interface RepositoryIdentity { + author: EffectiveIdentity; + committer: EffectiveIdentity; + local: GlobalIdentity; +} + +export type SigningMode = 'inherit' | 'sign' | 'unsigned'; +export type SigningScope = 'local' | 'worktree'; +export interface SigningSettings { + effective: Record; + local: Record; + worktree: Record; + worktree_enabled: boolean; + commit_sign: boolean; + tag_sign: boolean; + tag_force_annotated: boolean; +} +export interface TagVerification { + oid: string; + status: 'unsigned' | 'verified' | 'failed'; + output: string; +} diff --git a/ui/src/lib/userActions.test.ts b/ui/src/lib/userActions.test.ts new file mode 100644 index 00000000..0aa0401a --- /dev/null +++ b/ui/src/lib/userActions.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const stores = vi.hoisted(() => ({ repo: {} as Record, work: { repos: {} } as any, settings: { userActions: [] } as any })); +vi.mock('../stores/repo', () => ({ useRepo: { getState: () => stores.repo } })); +vi.mock('../stores/work', () => ({ useWork: { getState: () => stores.work } })); +vi.mock('../stores/settings', () => ({ useSettings: { getState: () => stores.settings } })); +import { parseActionArgs, selectedActionContext, userActionMenu, userActionPalette, type UserAction } from './userActions'; + +const action: UserAction = { id: 'test', name: 'Inspect', scope: 'file', executable: 'git', args: ['show', '--', '{relativeFile}'], cwd: 'repository' }; +beforeEach(() => { + stores.repo = { meta: { path: 'C:/repo one & %PATH%' }, view: 'work', selectedFile: 'stale.txt', selectedFileRevision: null, + selectedFileIsDirectory: false, selectedRef: null, refs: { branches: [], remote_branches: [], tags: [] } }; + stores.work = { repos: { [stores.repo.meta.path]: { activeTabId: 'a', tabs: [{ id: 'a', kind: 'file', path: 'a b & {repo}.txt', revision: null, isDirectory: false, missing: false }] } } }; + stores.settings = { userActions: [action] }; + vi.stubGlobal('window', { dispatchEvent: vi.fn() }); + vi.stubGlobal('CustomEvent', class { constructor(public type: string, public init: unknown) {} }); +}); + +describe('personal user actions', () => { + it('keeps argv boundaries, empties, spaces and metacharacters', () => { + expect(parseActionArgs('["", "a b;&%PATH%", "--", "{relativeFile}"]')).toEqual(['', 'a b;&%PATH%', '--', '{relativeFile}']); + for (const text of ['"git status"', '[1]', '["\\u0000"]', JSON.stringify(Array(129).fill('a'))]) expect(() => parseActionArgs(text)).toThrow(); + }); + it('uses the active Work document and hides historical/missing/directory/terminal targets', () => { + expect(selectedActionContext('file')?.target).toEqual({ kind: 'file', file: 'a b & {repo}.txt' }); + const file = stores.work.repos[stores.repo.meta.path].tabs[0]; + for (const override of [{ revision: 'abc' }, { isDirectory: true }, { missing: true }, { kind: 'terminal' }]) { + stores.work.repos[stores.repo.meta.path].tabs[0] = { ...file, ...override }; + expect(selectedActionContext('file')).toBeNull(); + } + }); + it('uses qualified explicitly selected refs, never HEAD or a ref with a different target', () => { + const ref = { full_name: 'refs/heads/topic', target: 'abc' }; + stores.repo = { ...stores.repo, view: 'commits', selectedRef: ref.full_name, selectedCommit: 'abc', refs: { branches: [ref], tags: [], remote_branches: [] } }; + expect(selectedActionContext('ref')?.target).toEqual({ kind: 'ref', reference: ref.full_name, oid: 'abc' }); + stores.repo.selectedCommit = 'def'; expect(selectedActionContext('ref')).toBeNull(); + }); + it('captures the clicked row even when another repository/file is selected', () => { + const context = { path: 'C:/other repo', target: { kind: 'file' as const, file: 'clicked.txt' } }; + const item = userActionMenu(context); + item.submenu?.[0].onSelect?.(); + expect(window.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ init: { detail: { context, actionId: 'test' } } })); + }); + it('refuses a superseded palette result instead of following new selection', () => { + const stale = vi.fn(); + const result = userActionPalette([action], stale)[0]; + stores.work.repos[stores.repo.meta.path].activeTabId = 'another'; + result.run(); expect(window.dispatchEvent).not.toHaveBeenCalled(); expect(stale).toHaveBeenCalledOnce(); + stores.work.repos[stores.repo.meta.path].activeTabId = 'a'; + const repoResult = userActionPalette([{ ...action, scope: 'repository' }], stale)[0]; + stores.repo.meta.path = 'C:/new repo'; + repoResult.run(); expect(window.dispatchEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/userActions.ts b/ui/src/lib/userActions.ts new file mode 100644 index 00000000..8f4065eb --- /dev/null +++ b/ui/src/lib/userActions.ts @@ -0,0 +1,88 @@ +import type { PaletteAction } from '../views/Palette'; +import type { MenuItem } from '../components/ContextMenu'; +import { useRepo } from '../stores/repo'; +import { useSettings } from '../stores/settings'; +import { useWork } from '../stores/work'; + +export interface UserAction { + id: string; + name: string; + scope: 'repository' | 'ref' | 'file'; + executable: string; + args: string[]; + cwd: 'repository' | 'file-parent'; +} +export interface ActionContext { + path: string; + target: { kind: 'repository' } | { kind: 'ref'; reference: string; oid: string } | { kind: 'file'; file: string }; +} +export interface ActionPreview { executable: string; args: string[]; cwd: string } +export interface ActionOutcome { + stdout: string; + stderr: string; + exit_code: number | null; + status: 'completed' | 'failed' | 'cancelled' | 'output-limit' | 'timed-out'; + truncated: boolean; + duration_ms: number; +} +export interface ActionRequest { context: ActionContext; actionId?: string } +export const USER_ACTION_EVENT = 'strand:user-action'; +export function openUserAction(context: ActionContext, actionId?: string) { + window.dispatchEvent(new CustomEvent(USER_ACTION_EVENT, { detail: { context, actionId } })); +} +export function userActionMenu(context: ActionContext): MenuItem { + const actions = useSettings.getState().userActions.filter((action) => action.scope === context.target.kind); + return { + label: 'User actions…', icon: 'terminal', + ...(actions.length ? { submenu: actions.map((action) => ({ + label: action.name, onSelect: () => openUserAction(context, action.id), + })) } : { onSelect: () => openUserAction(context) }), + }; +} + +/** Capture only the active surface's exact target, never an old file from + * another view or the current branch in place of the selected ref. */ +export function selectedActionContext(scope: UserAction['scope']): ActionContext | null { + const state = useRepo.getState(); + const path = state.meta?.path; + if (!path) return null; + if (scope === 'repository') return { path, target: { kind: 'repository' } }; + if (scope === 'ref') { + if (state.view !== 'commits') return null; + const matches = (candidate: { full_name: string }) => candidate.full_name === state.selectedRef; + const ref = state.refs.branches.find(matches) ?? state.refs.remote_branches.find(matches) ?? state.refs.tags.find(matches); + return ref && ref.target === state.selectedCommit ? { path, target: { kind: 'ref', reference: ref.full_name, oid: ref.target } } : null; + } + if (state.view === 'work') { + const work = useWork.getState().repos[path]; + const file = work?.tabs.find((tab) => tab.id === work.activeTabId); + return file?.kind === 'file' && !file.revision && !file.isDirectory && !file.missing + ? { path, target: { kind: 'file', file: file.path } } : null; + } + return state.view === 'file' && state.selectedFile && !state.selectedFileRevision && !state.selectedFileIsDirectory + ? { path, target: { kind: 'file', file: state.selectedFile } } : null; +} + +export function userActionPalette(actions: UserAction[], onStale: () => void): PaletteAction[] { + const contexts = new Map(); + return actions.flatMap((action) => { + if (!contexts.has(action.scope)) contexts.set(action.scope, selectedActionContext(action.scope)); + const context = contexts.get(action.scope); + if (!context) return []; + return [{ id: `user-action:${action.id}`, label: `User action: ${action.name}…`, group: 'Actions', + keywords: 'custom executable script command', meta: action.scope, + run: () => { + // A palette result is a captured target. Refuse superseded results. + if (JSON.stringify(context) !== JSON.stringify(selectedActionContext(action.scope))) { onStale(); return; } + openUserAction(context, action.id); + } } satisfies PaletteAction]; + }); +} + +export function parseActionArgs(text: string): string[] { + const value: unknown = JSON.parse(text); + if (!Array.isArray(value) || value.length > 128 || value.some((arg) => typeof arg !== 'string' || arg.includes('\0')) || text.length > 24_000) { + throw new Error('Arguments must be a JSON array of up to 128 strings / 24 KB.'); + } + return value; +} diff --git a/ui/src/lib/workTreeGitStatus.test.ts b/ui/src/lib/workTreeGitStatus.test.ts index 4f3af50b..09d32865 100644 --- a/ui/src/lib/workTreeGitStatus.test.ts +++ b/ui/src/lib/workTreeGitStatus.test.ts @@ -4,6 +4,12 @@ import type { WorkTreeEntry } from './types'; import { workTreeGitStatus } from './workTreeGitStatus'; describe('workTreeGitStatus', () => { + it('does not paint sparse-excluded files as deletions or ignored changes', () => { + expect(workTreeGitStatus([ + { path: 'excluded/file.txt', status: null, ignored: false, excluded: true }, + { path: 'included/deleted.txt', status: 'DELETED', ignored: false }, + ])).toEqual([{ path: 'included/deleted.txt', status: 'deleted' }]); + }); it('collapses a fully ignored tree into one muted directory status', () => { const entries: WorkTreeEntry[] = [ { path: 'node_modules/.bin/tool', status: null, ignored: true }, diff --git a/ui/src/lib/workTreeGitStatus.ts b/ui/src/lib/workTreeGitStatus.ts index 5576ae8c..226d1d0e 100644 --- a/ui/src/lib/workTreeGitStatus.ts +++ b/ui/src/lib/workTreeGitStatus.ts @@ -38,6 +38,7 @@ export function workTreeGitStatus( const directoryCounts = new Map(); for (const entry of entries) { + if (entry.excluded) continue; let separator = entry.path.indexOf('/'); while (separator >= 0) { const directory = entry.path.slice(0, separator); @@ -62,6 +63,7 @@ export function workTreeGitStatus( })); for (const entry of entries) { + if (entry.excluded) continue; if (entry.ignored) { if (!hasAncestor(entry.path, ignoredRoots)) { statuses.push({ path: entry.path, status: 'ignored' }); diff --git a/ui/src/plugins/builtins/heroi/HeroiView.tsx b/ui/src/plugins/builtins/heroi/HeroiView.tsx index ce2a5f80..7da2200e 100644 --- a/ui/src/plugins/builtins/heroi/HeroiView.tsx +++ b/ui/src/plugins/builtins/heroi/HeroiView.tsx @@ -392,7 +392,7 @@ export function HeroiView({ setSkills(discoveredSkills); }).catch(() => { if (!current) return; - setRepoFiles(useRepo.getState().workTree.map((entry) => entry.path)); + setRepoFiles(useRepo.getState().workTree.filter((entry) => !entry.excluded).map((entry) => entry.path)); setSkills([]); }); return () => { current = false; }; diff --git a/ui/src/stores/commitDrafts.ts b/ui/src/stores/commitDrafts.ts new file mode 100644 index 00000000..90ddd370 --- /dev/null +++ b/ui/src/stores/commitDrafts.ts @@ -0,0 +1,27 @@ +import type { SigningMode } from '../lib/types'; +import { create } from 'zustand'; + +interface CommitDraft { + subject: string; + body: string; + amend: boolean; + signing: SigningMode; + submitting: boolean; + output: string; + error: string | null; +} + +export const emptyCommitDraft: CommitDraft = { + subject: '', body: '', amend: false, signing: 'inherit', submitting: false, output: '', error: null, +}; + +/** Session drafts belong to a checkout, including while a hook is running. */ +export const useCommitDrafts = create<{ + drafts: Record; + patch(path: string, patch: Partial): void; +}>((set) => ({ + drafts: {}, + patch: (path, patch) => set((s) => ({ + drafts: { ...s.drafts, [path]: { ...(s.drafts[path] ?? emptyCommitDraft), ...patch } }, + })), +})); diff --git a/ui/src/stores/repo.test.ts b/ui/src/stores/repo.test.ts index 5d203f72..c898ffc6 100644 --- a/ui/src/stores/repo.test.ts +++ b/ui/src/stores/repo.test.ts @@ -26,10 +26,18 @@ afterEach(() => { }); describe('repository navigation state', () => { + it('drops the explicit action ref when its repository is deactivated', () => { + useRepo.setState({ activeTabPath: '/repo', activePath: '/repo' }); + useRepo.getState().selectRef('refs/heads/topic'); + useRepo.getState().deactivateTab(); + expect(useRepo.getState().selectedRef).toBeNull(); + }); + it('drops historical Files context when leaving history views', () => { useRepo.setState({ view: 'commits', selectedCommit: 'deadbeef', + selectedRef: 'refs/heads/topic', selectedCommitDiffs: [{ path: 'old.txt', old_path: null, @@ -47,6 +55,7 @@ describe('repository navigation state', () => { expect(useRepo.getState()).toMatchObject({ view: 'work', selectedCommit: null, + selectedRef: null, selectedCommitDiffs: [], selectedCommitDiffsLoading: false, }); @@ -144,3 +153,55 @@ describe('AI review notes', () => { }]); }); }); + + +describe('commit outcome boundary', () => { + it('propagates a hook rejection and refreshes its index changes', async () => { + const refresh = vi.fn(async () => {}); + const failure = { message: 'commit-msg rejected' }; + vi.spyOn(tauri, 'repoCommit').mockRejectedValue(failure); + useRepo.setState({ activePath: '/repo', refreshLocalChanges: refresh }); + await expect(useRepo.getState().commit('draft', 'body', true)).rejects.toBe(failure); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it('keeps a completed commit successful if refresh fails', async () => { + const outcome = { oid: 'abc', amended: false, output: 'hook accepted' }; + vi.spyOn(tauri, 'repoCommit').mockResolvedValue(outcome); + const refresh = vi.fn(async () => { throw new Error('refresh failed'); }); + useRepo.setState({ activePath: '/repo', refreshLocalChanges: refresh, + refreshLog: refresh, refreshStashes: refresh, refreshMeta: refresh, refreshRefs: refresh }); + await expect(useRepo.getState().commit('draft', null, false)).resolves.toEqual(outcome); + }); + + it('does not refresh a different checkout after a slow hook completes', async () => { + const refresh = vi.fn(async () => {}); + vi.spyOn(tauri, 'repoCommit').mockImplementation(async () => { + useRepo.setState({ activePath: '/other' }); + return { oid: 'abc', amended: false, output: '' }; + }); + useRepo.setState({ activePath: '/repo', refreshLocalChanges: refresh, refreshLog: refresh }); + await useRepo.getState().commit('draft', null, false); + expect(refresh).not.toHaveBeenCalled(); + }); +}); + +describe('tag outcome boundary', () => { + it('keeps a created signed tag successful if refresh fails', async () => { + vi.spyOn(tauri, 'repoTagCreate').mockResolvedValue(undefined); + const refresh = vi.fn(async () => { throw new Error('refresh failed'); }); + useRepo.setState({ activePath: '/repo', refreshRefs: refresh, refreshLog: refresh }); + await expect(useRepo.getState().createTag('release', null, 'annotation', 'sign')).resolves.toBeUndefined(); + expect(tauri.repoTagCreate).toHaveBeenCalledWith('/repo', 'release', null, 'annotation', false, 'sign'); + }); + + it('does not refresh another checkout after the signer completes', async () => { + const refresh = vi.fn(async () => {}); + vi.spyOn(tauri, 'repoTagCreate').mockImplementation(async () => { + useRepo.setState({ activePath: '/other' }); + }); + useRepo.setState({ activePath: '/repo', refreshRefs: refresh, refreshLog: refresh }); + await useRepo.getState().createTag('release', null, 'annotation'); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index 44ec9ae4..c9695292 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -23,6 +23,8 @@ import type { BaseBranch, CodeReviewFinding, Commit, + CommitOutcome, + SigningMode, BranchPushRequest, CommitSearchMode, FileDiff, @@ -431,7 +433,7 @@ export interface RepoState { loadRepoDiffMode(): Promise; stageAll(): Promise; unstageAll(): Promise; - commit(subject: string, body: string | null, amend: boolean): Promise; + commit(subject: string, body: string | null, amend: boolean, signing?: SigningMode): Promise; /** Re-read RepoMeta (branch, ahead/behind) for the active tab. */ refreshMeta(): Promise; @@ -541,7 +543,7 @@ export interface RepoState { * Create a tag at `target` (any revspec; null ⇒ HEAD). A non-empty * `message` makes it an annotated tag, otherwise lightweight. */ - createTag(name: string, target: string | null, message: string | null): Promise; + createTag(name: string, target: string | null, message: string | null, signing?: SigningMode): Promise; /** Delete a tag by short name. */ deleteTag(name: string): Promise; /** @@ -866,6 +868,7 @@ const EMPTY_ACTIVE = { fileReturn: null as string | null, workFileReturn: null as { repoPath: string; tabId: string; path: string } | null, selectedCommit: null as string | null, + selectedRef: null as string | null, selectedCommitDiffs: [] as FileDiff[], selectedCommitDiffsLoading: false, revealCommit: null as string | null, @@ -936,7 +939,6 @@ export const useRepo = create((set, get) => ({ view: useSettings.getState().startupSpace, fileTab: 'content', - selectedRef: null, commitSearchFocus: false, commitSearchMode: null, diffSearchSignal: false, @@ -1797,15 +1799,25 @@ export const useRepo = create((set, get) => ({ await tauri.repoUnstageMany(path, files); await get().refreshLocalChanges(); }, - async commit(subject, body, amend) { + async commit(subject, body, amend, signing = 'inherit') { const path = get().activePath; - if (!path) return; - await tauri.repoCommit(path, subject, body, amend); - await Promise.all([ - get().refreshLocalChanges(), - get().refreshLog(), - get().refreshStashes(), - ]); + if (!path) throw new Error('No repository selected.'); + let outcome: CommitOutcome; + try { + outcome = await tauri.repoCommit(path, subject, body, amend, signing); + } catch (error) { + // Hooks can edit the index/worktree even when they reject the commit. + if (get().activePath === path) await get().refreshLocalChanges().catch(() => {}); + throw error; + } + if (get().activePath === path) { + // A failed refresh cannot turn a completed commit into a retryable failure. + await Promise.allSettled([ + get().refreshLocalChanges(), get().refreshLog(), get().refreshStashes(), + get().refreshMeta(), get().refreshRefs(), + ]); + } + return outcome; }, async refreshMeta() { @@ -2108,12 +2120,12 @@ export const useRepo = create((set, get) => ({ await get().refreshLocalChanges(); }, - async createTag(name, target, message) { + async createTag(name, target, message, signing = 'inherit') { const path = get().activePath; if (!path) throw new Error('no repo open'); - await tauri.repoTagCreate(path, name, target, message, false); + await tauri.repoTagCreate(path, name, target, message, false, signing); // Refresh refs (sidebar list) and the log (graph chips read from refs). - await Promise.all([get().refreshRefs(), get().refreshLog()]); + if (get().activePath === path) await Promise.allSettled([get().refreshRefs(), get().refreshLog()]); }, async deleteTag(name) { const path = get().activePath; @@ -2358,6 +2370,7 @@ export const useRepo = create((set, get) => ({ ? {} : { selectedCommit: null, + selectedRef: null, selectedCommitDiffs: [], selectedCommitDiffsLoading: false, }), diff --git a/ui/src/stores/settings.ts b/ui/src/stores/settings.ts index b4391db2..dcadcba7 100644 --- a/ui/src/stores/settings.ts +++ b/ui/src/stores/settings.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import type { UserAction } from '../lib/userActions'; import { persist } from 'zustand/middleware'; import { t } from '../lib/i18n'; @@ -136,6 +137,7 @@ export interface SettingsState { defaultCloneDir: string | null; editorTool: ExternalTool; terminalTool: ExternalTool; + userActions: UserAction[]; /** Global shell used by Work terminals; a repository-family override may * replace it through the generic settings table. */ embeddedShell: EmbeddedShellChoice; @@ -279,6 +281,7 @@ export const useSettings = create()( defaultCloneDir: null, editorTool: null, terminalTool: null, + userActions: [], embeddedShell: { kind: 'system' }, terminalFont: 'jetbrains', terminalFontSize: 16, diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 03aa8eaa..7a2389c8 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -3447,6 +3447,19 @@ button { } /* ─── Clone dialog ─── */ +.clone-options-dialog { max-height: calc(100vh - 100px); } +.clone-options-dialog > .clone-body { overflow: auto; min-height: 0; overflow-wrap: anywhere; } +.clone-options-dialog > .clone-body > * { flex-shrink: 0; } +.clone-options-dialog > .clone-head, .clone-options-dialog > .clone-foot { flex-shrink: 0; } +.clone-advanced { display: flex; flex-direction: column; } +.clone-advanced > summary { cursor: pointer; font-weight: 600; } +.clone-advanced > :not(summary) { margin-top: 12px; } +.clone-scope-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.sparse-directory-list { display: flex; flex-direction: column; gap: 8px; max-height: 240px; overflow: auto; } +.sparse-directory-list label { display: flex; align-items: center; gap: 8px; } +.sparse-directory-list label span { flex: 1; overflow-wrap: anywhere; } +.sparse-directory-list small { color: var(--text-muted); } +.sparse-patterns { white-space: pre-wrap; overflow-wrap: anywhere; } .clone-dialog { width: 520px; max-width: calc(100vw - 64px); @@ -4317,6 +4330,11 @@ textarea.clone-input { min-height: 0; overflow-y: auto; } +.submodule-navigation { + display: flex; + flex-wrap: wrap; + gap: 6px; +} .maintenance-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -9542,3 +9560,58 @@ select.clone-input { .plugin-heroi-select-thinking, .plugin-heroi-select-permission { display: none; } } + +.pr-data-status { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 8px 16px; flex-shrink: 0; color: var(--text-muted); font-size: 12px; border-bottom: 1px solid var(--border); } + +.pr-data-status strong, .pr-data-status .h-link { color: var(--text); } +.pr-data-status [role="alert"] { color: var(--del); } + +/* Hosted review tools share the revision comparison surface. */ +.hosted-review-dialog { display: flex; flex-direction: column; } +.hosted-review-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.hosted-review-toolbar label, .hosted-review-suggestions > label { display: flex; align-items: center; gap: 8px; min-width: 0; } +.hosted-review-toolbar .select-control { max-width: 370px; min-width: 120px; } +.hosted-review-dialog .compare-refs-message { padding: 8px 12px; color: var(--text-muted); } +.hosted-review-status { padding: 0 12px; color: var(--text-muted); overflow-wrap: anywhere; } +.hosted-review-status:not(:empty) { padding-block: 8px; } +.hosted-review-status [role="alert"] { display: block; color: var(--del); } +.hosted-review-dialog .compare-refs-body { flex: 1; } +.hosted-review-dialog .compare-refs-file { grid-template-columns: minmax(0, 1fr) auto; } +.hosted-review-export, .hosted-review-preview textarea { resize: none; border: 1px solid var(--border); background: var(--bg); color: var(--text); font-family: var(--font-mono); padding: 12px; line-height: 1.5; } +.hosted-review-export { flex: 1; min-height: 0; margin: 12px; } +.hosted-review-suggestions { flex: 1; min-height: 0; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; } +.hosted-review-suggestions p { margin: 0; color: var(--text-muted); } +.hosted-review-suggestions .select-control { flex: 1; min-width: 0; } +.hosted-review-suggestions .hosted-review-toolbar { padding: 0 0 12px; } +.hosted-review-preview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; gap: 12px; min-height: 150px; } +.hosted-review-preview label { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.hosted-review-preview textarea { flex: 1; width: 100%; min-height: 100px; } + +.hosted-review-dialog .select-control select { width: 100%; min-width: 0; height: 30px; padding: 0 28px 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-elev); color: var(--text); } + +.publish-review { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 14px; margin: 0; } +.publish-review dt { color: var(--text-muted); } +.publish-review dd { margin: 0; overflow-wrap: anywhere; } +.publish-review a { color: var(--accent); } + +/* Repository tools mount only while their dialog is open. */ +.git-tool-body { overflow-y: auto; min-height: 0; max-height: 70vh; } +.git-tool-body > .btn { align-self: flex-start; } +.git-tool-body p { overflow-wrap: anywhere; } +.git-tool-review li { overflow-wrap: anywhere; } +.git-tool-review { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.git-tool-review pre, .git-tool-output { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow-y: auto; font: 11px var(--font-mono); } +.git-tool-actions { display: flex; flex-wrap: wrap; gap: 8px; } + + +/* Git hook transcripts stay selectable and bounded in the commit form. */ +.cb-output, .cb-error { max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.cb-output { font-size: var(--type-ui-sm); color: var(--text-2); } +.cb-output pre { white-space: pre-wrap; margin: 6px 0; } + +.signing-choice { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: var(--type-ui-sm); color: var(--text-2); } +.signing-choice label { display: flex; align-items: center; gap: 6px; } +.signing-choice select { width: auto; padding: 3px 6px; font-size: inherit; } +.settings-field .settings-hint { overflow-wrap: anywhere; } + +.tag-verification-output { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 300px; overflow: auto; font-size: var(--type-ui-sm); } diff --git a/ui/src/styles/user-actions.css b/ui/src/styles/user-actions.css new file mode 100644 index 00000000..77f6556f --- /dev/null +++ b/ui/src/styles/user-actions.css @@ -0,0 +1,13 @@ +.user-action-body { overflow: auto; min-height: 0; } +.user-action-body label, .user-actions-editor label { display: flex; flex-direction: column; gap: 6px; } +.user-action-body dl { display: grid; gap: 5px; margin: 14px 0; } +.user-action-body dt { color: var(--text-muted); } +.user-action-body dd { margin: 0; overflow-wrap: anywhere; font-family: var(--font-mono); } +.user-action-preview { border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; } +.user-action-preview li { white-space: pre-wrap; overflow-wrap: anywhere; padding: 3px; } +.user-action-body pre { max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg-panel); padding: 10px; } +.user-actions-editor { border-top: 1px solid var(--border); padding-top: 16px; gap: 10px; } +.user-actions-editor textarea { width: 100%; resize: vertical; font-family: var(--font-mono); } +.user-actions-buttons { display: flex; gap: 8px; } +.user-actions-editor .settings-select, .user-action-body .settings-select { width: 100%; } +.user-actions-editor h3 { margin: 0; } diff --git a/ui/src/views/AdvancedRefsDialog.tsx b/ui/src/views/AdvancedRefsDialog.tsx new file mode 100644 index 00000000..f8a0fae2 --- /dev/null +++ b/ui/src/views/AdvancedRefsDialog.tsx @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { Dialog } from '../components/Dialog'; +import { Select } from '../components/Select'; +import { errMessage, tauri } from '../lib/tauri'; +import type { AdvancedRefs, GitNote, ReplaceReview, TagEditKind, TagEditReview } from '../lib/advancedRefs'; +import { useRepo } from '../stores/repo'; + +export function AdvancedRefsDialog({ path, initialMode = 'notes', initialTag = '', onClose }: { path: string; initialMode?: 'notes' | 'replace' | TagEditKind; initialTag?: string; onClose: () => void }) { + const [mode, setMode] = useState(initialMode); + const [notesRef, setNotesRef] = useState('refs/notes/commits'); + const [data, setData] = useState(null); + const [revision, setRevision] = useState('HEAD'); + const [note, setNote] = useState(null); + const [message, setMessage] = useState(''); + const [original, setOriginal] = useState(''); + const [replacement, setReplacement] = useState(''); + const [replaceReview, setReplaceReview] = useState(null); + const [tag, setTag] = useState(initialTag); + const [target, setTarget] = useState('HEAD'); + const [annotation, setAnnotation] = useState(''); + const [tagReview, setTagReview] = useState(null); + const [remote, setRemote] = useState(''); + const [published, setPublished] = useState('Publication has not been checked.'); + const [acknowledged, setAcknowledged] = useState(false); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [output, setOutput] = useState(''); + const first = useRef(null); + const mounted = useRef(true); + const reads = useRef(0); + const refresh = useCallback(async () => { + const seq = ++reads.current; + try { const next = await tauri.repoAdvancedRefs(path, notesRef); if (mounted.current && seq === reads.current) setData(next); } + catch (e) { if (mounted.current && seq === reads.current) { setData(null); setError(errMessage(e)); } } + }, [path, notesRef]); + useEffect(() => { + mounted.current = true; + void refresh(); + const changed = () => { setConfirm(''); void refresh(); }; + // Any tab in this repository family can change the shared refs. Reads remain + // confined to this open dialog; no advanced-ref work rides the repo snapshot. + const unlisten = listen('repo://changed', changed); + window.addEventListener('focus', changed); + return () => { mounted.current = false; reads.current++; window.removeEventListener('focus', changed); void unlisten.then((fn) => fn()); }; + }, [refresh]); + useEffect(() => { const focus = requestAnimationFrame(() => first.current?.focus()); return () => cancelAnimationFrame(focus); }, []); + async function run(work: () => Promise, mutation = false) { + if (busy) return; + setBusy(true); setError(''); + try { await work(); if (mutation) { setOutput('Local Git reference updated.'); setConfirm(''); } } + catch (e) { if (mounted.current) setError(errMessage(e)); } + finally { + if (mutation) { await refresh(); const repo = useRepo.getState(); if (repo.activePath === path) await Promise.all([repo.refreshLocalChanges(), repo.refreshLog()]); } + if (mounted.current) { setBusy(false); requestAnimationFrame(() => first.current?.focus()); } + } + } + async function inspectNote(object = revision) { const read = await tauri.repoGitNote(path, notesRef, object); setNote(read); setMessage(read.message ?? ''); setRevision(object); setConfirm(''); } + async function inspectTag() { + const review = await tauri.repoTagEditReview(path, tag, mode === 'reannotate' ? `refs/tags/${tag}` : target); + setTagReview(review); setAnnotation(review.annotation ?? ''); setRemote(review.remotes[0] ?? ''); setAcknowledged(false); setPublished('Publication has not been checked.'); setConfirm(''); + } + function clearTag() { setTagReview(null); setAcknowledged(false); setConfirm(''); } + const tags = useRepo((s) => s.refs.tags); + return {busy ? 'Working with Git…' : ''}}> +
+

Repository: {path}. These are Git objects and refs, separate from Strand’s local Review notes.

+ + {mode === 'notes' && <> + + + + + {note &&
+ {note.target.oid}

{note.target.kind} · {note.target.subject}

+